From ac35dae66ec56b18e20de5665c8b48c747ec190d Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 18 Jul 2023 18:55:54 -0700 Subject: [PATCH 001/105] Add channels panel with stubbed out information co-authored-by: nate --- Cargo.lock | 26 ++ Cargo.toml | 1 + assets/settings/default.json | 6 + crates/channels/Cargo.toml | 38 ++ crates/channels/src/channels.rs | 103 +++++ crates/channels/src/channels_panel.rs | 369 ++++++++++++++++++ .../channels/src/channels_panel_settings.rs | 37 ++ crates/gpui/src/elements/flex.rs | 7 + crates/project_panel/src/project_panel.rs | 24 -- crates/theme/src/theme.rs | 80 ++++ crates/theme/src/ui.rs | 10 + crates/workspace/src/dock.rs | 28 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 17 +- styles/src/style_tree/app.ts | 2 + styles/src/style_tree/channels_panel.ts | 68 ++++ 17 files changed, 784 insertions(+), 34 deletions(-) create mode 100644 crates/channels/Cargo.toml create mode 100644 crates/channels/src/channels.rs create mode 100644 crates/channels/src/channels_panel.rs create mode 100644 crates/channels/src/channels_panel_settings.rs create mode 100644 styles/src/style_tree/channels_panel.ts diff --git a/Cargo.lock b/Cargo.lock index 535c20bcb9d80a6b01067b56e3a0f2a3045ab26b..e0a4b6a7bf8a9231099016f541b7f5c0da2caa5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,6 +1254,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "channels" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "collections", + "context_menu", + "db", + "editor", + "futures 0.3.28", + "gpui", + "log", + "menu", + "project", + "schemars", + "serde", + "serde_derive", + "serde_json", + "settings", + "theme", + "util", + "workspace", +] + [[package]] name = "chrono" version = "0.4.26" @@ -9857,6 +9882,7 @@ dependencies = [ "backtrace", "breadcrumbs", "call", + "channels", "chrono", "cli", "client", diff --git a/Cargo.toml b/Cargo.toml index 6e79c6b6576c290a4a0e85e56a80cfe9b100ff2b..8803d1c34b02be3938870f58c212a06e54e15572 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/auto_update", "crates/breadcrumbs", "crates/call", + "crates/channels", "crates/cli", "crates/client", "crates/clock", diff --git a/assets/settings/default.json b/assets/settings/default.json index 397dac0961397f2dc5c34902383534e51fcb3400..c40ed4e8da2dc99ccdf7f681f7ee69fe0829b405 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -122,6 +122,12 @@ // Amount of indentation for nested items. "indent_size": 20 }, + "channels_panel": { + // Where to dock channels panel. Can be 'left' or 'right'. + "dock": "left", + // Default width of the channels panel. + "default_width": 240 + }, "assistant": { // Where to dock the assistant. Can be 'left', 'right' or 'bottom'. "dock": "right", diff --git a/crates/channels/Cargo.toml b/crates/channels/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..75070721306306ee9600d77d67d17aed1710cafe --- /dev/null +++ b/crates/channels/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "channels" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/channels.rs" +doctest = false + +[dependencies] +collections = { path = "../collections" } +context_menu = { path = "../context_menu" } +client = { path = "../client" } +db = { path = "../db" } +editor = { path = "../editor" } +gpui = { path = "../gpui" } +project = { path = "../project" } +theme = { path = "../theme" } +settings = { path = "../settings" } +workspace = { path = "../workspace" } +menu = { path = "../menu" } +util = { path = "../util" } + +log.workspace = true +anyhow.workspace = true +schemars.workspace = true +serde_json.workspace = true +serde.workspace = true +serde_derive.workspace = true +futures.workspace = true + +[dev-dependencies] +client = { path = "../client", features = ["test-support"] } +editor = { path = "../editor", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } +serde_json.workspace = true diff --git a/crates/channels/src/channels.rs b/crates/channels/src/channels.rs new file mode 100644 index 0000000000000000000000000000000000000000..8e55441b29fea844757a18b1a85cc14dc8ed6e2c --- /dev/null +++ b/crates/channels/src/channels.rs @@ -0,0 +1,103 @@ +mod channels_panel; +mod channels_panel_settings; + +pub use channels_panel::*; +use gpui::{AppContext, Entity}; + +use std::sync::Arc; + +use client::Client; + +pub fn init(client: Arc, cx: &mut AppContext) { + let channels = cx.add_model(|cx| Channels::new(client, cx)); + cx.set_global(channels); + channels_panel::init(cx); +} + +#[derive(Debug, Clone)] +struct Channel { + id: u64, + name: String, + sub_channels: Vec, + _room: Option<()>, +} + +impl Channel { + fn new(id: u64, name: impl AsRef, members: Vec) -> Channel { + Channel { + name: name.as_ref().to_string(), + id, + sub_channels: members, + _room: None, + } + } + + fn members(&self) -> &[Channel] { + &self.sub_channels + } + + fn name(&self) -> &str { + &self.name + } +} + +struct Channels { + channels: Vec, +} + +impl Channels { + fn channels(&self) -> Vec { + self.channels.clone() + } +} + +enum ChannelEvents {} + +impl Entity for Channels { + type Event = ChannelEvents; +} + +impl Channels { + fn new(_client: Arc, _cx: &mut AppContext) -> Self { + //TODO: Subscribe to channel updates from the server + Channels { + channels: vec![Channel::new( + 0, + "Zed Industries", + vec![ + Channel::new(1, "#general", Vec::new()), + Channel::new(2, "#admiral", Vec::new()), + Channel::new(3, "#livestreaming", vec![]), + Channel::new(4, "#crdb", Vec::new()), + Channel::new(5, "#crdb-1", Vec::new()), + Channel::new(6, "#crdb-2", Vec::new()), + Channel::new(7, "#crdb-3", vec![]), + Channel::new(8, "#crdb-4", Vec::new()), + Channel::new(9, "#crdb-1", Vec::new()), + Channel::new(10, "#crdb-1", Vec::new()), + Channel::new(11, "#crdb-1", Vec::new()), + Channel::new(12, "#crdb-1", vec![]), + Channel::new(13, "#crdb-1", Vec::new()), + Channel::new(14, "#crdb-1", Vec::new()), + Channel::new(15, "#crdb-1", Vec::new()), + Channel::new(16, "#crdb-1", Vec::new()), + Channel::new(17, "#crdb", vec![]), + ], + ), + Channel::new( + 18, + "CRDB Consulting", + vec![ + Channel::new(19, "#crdb 😭", Vec::new()), + Channel::new(20, "#crdb 😌", Vec::new()), + Channel::new(21, "#crdb 🦀", vec![]), + Channel::new(22, "#crdb 😤", Vec::new()), + Channel::new(23, "#crdb 😤", Vec::new()), + Channel::new(24, "#crdb 😤", Vec::new()), + Channel::new(25, "#crdb 😤", vec![]), + Channel::new(26, "#crdb 😤", Vec::new()), + ], + )], + } + } +} diff --git a/crates/channels/src/channels_panel.rs b/crates/channels/src/channels_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..73697b3b7249ec19878c482575df926fd16b0545 --- /dev/null +++ b/crates/channels/src/channels_panel.rs @@ -0,0 +1,369 @@ +use std::sync::Arc; + +use crate::{ + channels_panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}, + Channel, Channels, +}; +use anyhow::Result; +use collections::HashMap; +use context_menu::ContextMenu; +use db::kvp::KEY_VALUE_STORE; +use gpui::{ + actions, + elements::{ChildView, Empty, Flex, Label, MouseEventHandler, ParentElement, Stack}, + serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, Task, View, + ViewContext, ViewHandle, WeakViewHandle, +}; +use project::Fs; +use serde_derive::{Deserialize, Serialize}; +use settings::SettingsStore; +use theme::ChannelTreeStyle; +use util::{ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel}, + Workspace, +}; + +actions!(channels, [ToggleFocus]); + +const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; + +pub fn init(cx: &mut AppContext) { + settings::register::(cx); +} + +pub struct ChannelsPanel { + width: Option, + fs: Arc, + has_focus: bool, + pending_serialization: Task>, + channels: ModelHandle, + context_menu: ViewHandle, + collapsed_channels: HashMap, +} + +#[derive(Serialize, Deserialize)] +struct SerializedChannelsPanel { + width: Option, + collapsed_channels: Option>, +} + +#[derive(Debug)] +pub enum Event { + DockPositionChanged, + Focus, +} + +impl Entity for ChannelsPanel { + type Event = Event; +} + +impl ChannelsPanel { + pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { + cx.add_view(|cx| { + let view_id = cx.view_id(); + let this = Self { + width: None, + has_focus: false, + fs: workspace.app_state().fs.clone(), + pending_serialization: Task::ready(None), + channels: cx.global::>().clone(), + context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), + collapsed_channels: HashMap::default(), + }; + + // Update the dock position when the setting changes. + let mut old_dock_position = this.position(cx); + cx.observe_global::(move |this: &mut ChannelsPanel, 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); + } + }) + .detach(); + + this + }) + } + + pub fn load( + workspace: WeakViewHandle, + cx: AsyncAppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let serialized_panel = if let Some(panel) = cx + .background() + .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) }) + .await + .log_err() + .flatten() + { + Some(serde_json::from_str::(&panel)?) + } else { + None + }; + + workspace.update(&mut cx, |workspace, cx| { + let panel = ChannelsPanel::new(workspace, cx); + if let Some(serialized_panel) = serialized_panel { + panel.update(cx, |panel, cx| { + panel.width = serialized_panel.width; + panel.collapsed_channels = + serialized_panel.collapsed_channels.unwrap_or_default(); + cx.notify(); + }); + } + panel + }) + }) + } + + fn serialize(&mut self, cx: &mut ViewContext) { + let width = self.width; + let collapsed_channels = self.collapsed_channels.clone(); + self.pending_serialization = cx.background().spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + CHANNELS_PANEL_KEY.into(), + serde_json::to_string(&SerializedChannelsPanel { + width, + collapsed_channels: Some(collapsed_channels), + })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ); + } + + fn render_channel( + &mut self, + depth: usize, + channel: &Channel, + style: &ChannelTreeStyle, + root: bool, + cx: &mut ViewContext, + ) -> AnyElement { + let has_chilren = !channel.members().is_empty(); + + let sub_channel_details = has_chilren.then(|| { + let mut sub_channels = Flex::column(); + let collapsed = self + .collapsed_channels + .get(&channel.id) + .copied() + .unwrap_or_default(); + if !collapsed { + for sub_channel in channel.members() { + sub_channels = sub_channels.with_child(self.render_channel( + depth + 1, + sub_channel, + style, + false, + cx, + )); + } + } + (sub_channels, collapsed) + }); + + let channel_id = channel.id; + + enum ChannelCollapser {} + Flex::row() + .with_child( + Empty::new() + .constrained() + .with_width(depth as f32 * style.channel_indent), + ) + .with_child( + Flex::column() + .with_child( + Flex::row() + .with_child( + sub_channel_details + .as_ref() + .map(|(_, expanded)| { + MouseEventHandler::::new( + channel.id as usize, + cx, + |state, _cx| { + let icon = + style.channel_icon.style_for(!*expanded, state); + theme::ui::icon(icon) + }, + ) + .on_click( + gpui::platform::MouseButton::Left, + move |_, v, cx| { + let entry = v + .collapsed_channels + .entry(channel_id) + .or_default(); + *entry = !*entry; + v.serialize(cx); + cx.notify(); + }, + ) + .into_any() + }) + .unwrap_or_else(|| { + Empty::new() + .constrained() + .with_width(style.channel_icon.default_style().width()) + .into_any() + }), + ) + .with_child( + Label::new( + channel.name().to_string(), + if root { + style.root_name.clone() + } else { + style.channel_name.clone() + }, + ) + .into_any(), + ), + ) + .with_children(sub_channel_details.map(|(elements, _)| elements)), + ) + .into_any() + } +} + +impl View for ChannelsPanel { + fn ui_name() -> &'static str { + "ChannelsPanel" + } + + fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + if !self.has_focus { + self.has_focus = true; + cx.emit(Event::Focus); + } + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + self.has_focus = false; + } + + fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { + let theme = theme::current(cx).clone(); + + let mut channels_column = Flex::column(); + for channel in self.channels.read(cx).channels() { + channels_column = channels_column.with_child(self.render_channel( + 0, + &channel, + &theme.channels_panel.channel_tree, + true, + cx, + )); + } + + let spacing = theme.channels_panel.spacing; + + enum ChannelsPanelScrollTag {} + Stack::new() + .with_child( + // Full panel column + Flex::column() + .with_spacing(spacing) + .with_child( + // Channels section column + Flex::column() + .with_child( + Flex::row().with_child( + Label::new( + "Active Channels", + theme.editor.invalid_information_diagnostic.message.clone(), + ) + .into_any(), + ), + ) + // Channels list column + .with_child(channels_column), + ) + // TODO: Replace with spacing implementation + .with_child(Empty::new().constrained().with_height(spacing)) + .with_child( + Flex::column().with_child( + Flex::row().with_child( + Label::new( + "Contacts", + theme.editor.invalid_information_diagnostic.message.clone(), + ) + .into_any(), + ), + ), + ) + .scrollable::(0, None, cx) + .expanded(), + ) + .with_child(ChildView::new(&self.context_menu, cx)) + .into_any_named("channels panel") + .into_any() + } +} + +impl Panel for ChannelsPanel { + fn position(&self, cx: &gpui::WindowContext) -> DockPosition { + match settings::get::(cx).dock { + ChannelsPanelDockPosition::Left => DockPosition::Left, + ChannelsPanelDockPosition::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + matches!(position, DockPosition::Left | DockPosition::Right) + } + + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::( + self.fs.clone(), + cx, + move |settings| { + let dock = match position { + DockPosition::Left | DockPosition::Bottom => ChannelsPanelDockPosition::Left, + DockPosition::Right => ChannelsPanelDockPosition::Right, + }; + settings.dock = Some(dock); + }, + ); + } + + fn size(&self, cx: &gpui::WindowContext) -> f32 { + self.width + .unwrap_or_else(|| settings::get::(cx).default_width) + } + + fn set_size(&mut self, size: f32, cx: &mut ViewContext) { + self.width = Some(size); + self.serialize(cx); + cx.notify(); + } + + fn icon_path(&self) -> &'static str { + "icons/bolt_16.svg" + } + + fn icon_tooltip(&self) -> (String, Option>) { + ("Channels Panel".to_string(), Some(Box::new(ToggleFocus))) + } + + fn should_change_position_on_event(event: &Self::Event) -> bool { + matches!(event, Event::DockPositionChanged) + } + + fn has_focus(&self, _cx: &gpui::WindowContext) -> bool { + self.has_focus + } + + fn is_focus_event(event: &Self::Event) -> bool { + matches!(event, Event::Focus) + } +} diff --git a/crates/channels/src/channels_panel_settings.rs b/crates/channels/src/channels_panel_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..fe3484b782914dae3a42d50b39cd57b2477173ab --- /dev/null +++ b/crates/channels/src/channels_panel_settings.rs @@ -0,0 +1,37 @@ +use anyhow; +use schemars::JsonSchema; +use serde_derive::{Deserialize, Serialize}; +use settings::Setting; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ChannelsPanelDockPosition { + Left, + Right, +} + +#[derive(Deserialize, Debug)] +pub struct ChannelsPanelSettings { + pub dock: ChannelsPanelDockPosition, + pub default_width: f32, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct ChannelsPanelSettingsContent { + pub dock: Option, + pub default_width: Option, +} + +impl Setting for ChannelsPanelSettings { + const KEY: Option<&'static str> = Some("channels_panel"); + + type FileContent = ChannelsPanelSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index 857f3f56fc08b0b24f39011d7f4323838b97dde2..40959c8f5c2eb3c57b03b938f80c9e8e1c1eeedc 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/crates/gpui/src/elements/flex.rs @@ -22,6 +22,7 @@ pub struct Flex { children: Vec>, scroll_state: Option<(ElementStateHandle>, usize)>, child_alignment: f32, + spacing: f32, } impl Flex { @@ -31,6 +32,7 @@ impl Flex { children: Default::default(), scroll_state: None, child_alignment: -1., + spacing: 0., } } @@ -42,6 +44,11 @@ impl Flex { Self::new(Axis::Vertical) } + pub fn with_spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + /// Render children centered relative to the cross-axis of the parent flex. /// /// If this is a flex row, children will be centered vertically. If this is a diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e6e1cff5981cf7450c154cd7173ab195c5190751..67a23f8d77b02a4af736da9a0f84de5aab5f37d2 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1649,22 +1649,6 @@ impl workspace::dock::Panel for ProjectPanel { cx.notify(); } - fn should_zoom_in_on_event(_: &Self::Event) -> bool { - false - } - - fn should_zoom_out_on_event(_: &Self::Event) -> bool { - false - } - - fn is_zoomed(&self, _: &WindowContext) -> bool { - false - } - - fn set_zoomed(&mut self, _: bool, _: &mut ViewContext) {} - - fn set_active(&mut self, _: bool, _: &mut ViewContext) {} - fn icon_path(&self) -> &'static str { "icons/folder_tree_16.svg" } @@ -1677,14 +1661,6 @@ impl workspace::dock::Panel for ProjectPanel { matches!(event, Event::DockPositionChanged) } - fn should_activate_on_event(_: &Self::Event) -> bool { - false - } - - fn should_close_on_event(_: &Self::Event) -> bool { - false - } - fn has_focus(&self, _: &WindowContext) -> bool { self.has_focus } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 4766f636f37bbef734e364eaff95e0fb63e152ac..844b093a5eaf5e93a177e83faeff542b9b95d516 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -49,6 +49,7 @@ pub struct Theme { pub copilot: Copilot, pub contact_finder: ContactFinder, pub project_panel: ProjectPanel, + pub channels_panel: ChanelsPanelStyle, pub command_palette: CommandPalette, pub picker: Picker, pub editor: Editor, @@ -880,6 +881,16 @@ impl Interactive { } } +impl Toggleable> { + pub fn style_for(&self, active: bool, state: &mut MouseState) -> &T { + self.in_state(active).style_for(state) + } + + pub fn default_style(&self) -> &T { + &self.inactive.default + } +} + impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive { fn deserialize(deserializer: D) -> Result where @@ -1045,6 +1056,75 @@ pub struct AssistantStyle { pub saved_conversation: SavedConversation, } +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct Contained { + container: ContainerStyle, + contained: T, +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct FlexStyle { + // Between item spacing + item_spacing: f32, +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ChannelProjectStyle { + // TODO: Implement Contained Flex + // ContainerStyle + Spacing between elements + // Negative spacing overlaps elements instead of spacing them out + pub container: Contained, + pub host: ImageStyle, + pub title: ContainedText, + pub members: Contained, + pub member: ImageStyle +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ChanneltemStyle { + pub icon: IconStyle, + pub title: TextStyle, +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ChannelListStyle { + pub section_title: ContainedText, + pub channel: Toggleable>, + pub project: ChannelProjectStyle +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ContactItemStyle { + pub container: Contained, + pub avatar: IconStyle, + pub name: TextStyle, +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ContactsListStyle { + pub section_title: ContainedText, + pub contact: ContactItemStyle, +} + + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ChannelTreeStyle { + pub channel_indent: f32, + pub channel_name: TextStyle, + pub root_name: TextStyle, + pub channel_icon: Toggleable>, +} + +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct ChanelsPanelStyle { + pub channel_tree: ChannelTreeStyle, + pub spacing: f32, + // TODO: Uncomment: + // pub container: ContainerStyle, + // pub channel_list: ChannelListStyle, + // pub contacts_list: ContactsListStyle +} + #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct SavedConversation { pub container: Interactive, diff --git a/crates/theme/src/ui.rs b/crates/theme/src/ui.rs index 308ea6f2d7b5e02c222a4e9f049ce58fa866e2bd..76f6883f0ecce849cf9341a3218af63df44c9c93 100644 --- a/crates/theme/src/ui.rs +++ b/crates/theme/src/ui.rs @@ -107,6 +107,16 @@ pub struct IconStyle { pub container: ContainerStyle, } +impl IconStyle { + pub fn width(&self) -> f32 { + self.icon.dimensions.width + + self.container.padding.left + + self.container.padding.right + + self.container.margin.left + + self.container.margin.right + } +} + pub fn icon(style: &IconStyle) -> Container { svg(&style.icon).contained().with_style(style.container) } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index ebaf399e22b3ecfa51b217327f5912a34dc7b542..3b0dc8192015e74bf17913fdaca2c0cdfbb42b8a 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -20,13 +20,27 @@ pub trait Panel: View { None } fn should_change_position_on_event(_: &Self::Event) -> bool; - fn should_zoom_in_on_event(_: &Self::Event) -> bool; - fn should_zoom_out_on_event(_: &Self::Event) -> bool; - fn is_zoomed(&self, cx: &WindowContext) -> bool; - fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext); - fn set_active(&mut self, active: bool, cx: &mut ViewContext); - fn should_activate_on_event(_: &Self::Event) -> bool; - fn should_close_on_event(_: &Self::Event) -> bool; + fn should_zoom_in_on_event(_: &Self::Event) -> bool { + false + } + fn should_zoom_out_on_event(_: &Self::Event) -> bool { + false + } + fn is_zoomed(&self, _cx: &WindowContext) -> bool { + false + } + fn set_zoomed(&mut self, _zoomed: bool, _cx: &mut ViewContext) { + + } + fn set_active(&mut self, _active: bool, _cx: &mut ViewContext) { + + } + fn should_activate_on_event(_: &Self::Event) -> bool { + false + } + fn should_close_on_event(_: &Self::Event) -> bool { + false + } fn has_focus(&self, cx: &WindowContext) -> bool; fn is_focus_event(_: &Self::Event) -> bool; } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a5877aaccb41a390776a16d8627653d2c0afa40c..71d8461b01316b2382183afb3fba0a096d90b8b6 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -21,6 +21,7 @@ activity_indicator = { path = "../activity_indicator" } auto_update = { path = "../auto_update" } breadcrumbs = { path = "../breadcrumbs" } call = { path = "../call" } +channels = { path = "../channels" } cli = { path = "../cli" } collab_ui = { path = "../collab_ui" } collections = { path = "../collections" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e44ab3e33acbb213f69b230a80075aea07890c85..5739052b675bcc663f37beb1e10900a0bfe94de4 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -155,6 +155,7 @@ fn main() { outline::init(cx); project_symbols::init(cx); project_panel::init(Assets, cx); + channels::init(client.clone(), cx); diagnostics::init(cx); search::init(cx); semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4b0bf1cd4c6aec42aa2d636b474828ef452cbe03..c1046c09958f6ab0e92819ceaf1b7d1bfbfbe627 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -9,6 +9,7 @@ use ai::AssistantPanel; use anyhow::Context; use assets::Assets; use breadcrumbs::Breadcrumbs; +use channels::ChannelsPanel; pub use client; use collab_ui::{CollabTitlebarItem, ToggleContactsMenu}; use collections::VecDeque; @@ -221,6 +222,11 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { workspace.toggle_panel_focus::(cx); }, ); + cx.add_action( + |workspace: &mut Workspace, _: &channels::ToggleFocus, cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ); cx.add_action( |workspace: &mut Workspace, _: &terminal_panel::ToggleFocus, @@ -339,9 +345,13 @@ pub fn initialize_workspace( let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); - let (project_panel, terminal_panel, assistant_panel) = - futures::try_join!(project_panel, terminal_panel, assistant_panel)?; - + let channels_panel = ChannelsPanel::load(workspace_handle.clone(), cx.clone()); + let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!( + project_panel, + terminal_panel, + assistant_panel, + channels_panel + )?; workspace_handle.update(&mut cx, |workspace, cx| { let project_panel_position = project_panel.position(cx); workspace.add_panel_with_extra_event_handler( @@ -359,6 +369,7 @@ pub fn initialize_workspace( ); workspace.add_panel(terminal_panel, cx); workspace.add_panel(assistant_panel, cx); + workspace.add_panel(channels_panel, cx); if !was_deserialized && workspace diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index ee0aa133a04bd35202d381835e257ddca2b284cc..d504f8e623ba9436167ec0c9f2f95f9cd8e2d6fe 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -24,6 +24,7 @@ import { titlebar } from "./titlebar" import editor from "./editor" import feedback from "./feedback" import { useTheme } from "../common" +import channels_panel from "./channels_panel" export default function app(): any { const theme = useTheme() @@ -46,6 +47,7 @@ export default function app(): any { editor: editor(), project_diagnostics: project_diagnostics(), project_panel: project_panel(), + channels_panel: channels_panel(), contacts_popover: contacts_popover(), contact_finder: contact_finder(), contact_list: contact_list(), diff --git a/styles/src/style_tree/channels_panel.ts b/styles/src/style_tree/channels_panel.ts new file mode 100644 index 0000000000000000000000000000000000000000..b46db5dc38edd92d396aa1ed8899f1d8e4dbbd6b --- /dev/null +++ b/styles/src/style_tree/channels_panel.ts @@ -0,0 +1,68 @@ +// import { with_opacity } from "../theme/color" +import { + // Border, + // TextStyle, + // background, + // border, + foreground, + text, +} from "./components" +import { interactive, toggleable } from "../element" +// import merge from "ts-deepmerge" +import { useTheme } from "../theme" +export default function channels_panel(): any { + const theme = useTheme() + + // const { is_light } = theme + + return { + spacing: 10, + channel_tree: { + channel_indent: 10, + channel_name: text(theme.middle, "sans", "variant", { size: "md" }), + root_name: text(theme.middle, "sans", "variant", { size: "lg", weight: "bold" }), + channel_icon: (() => { + const base_icon = (asset: any, color: any) => { + return { + icon: { + color, + asset, + dimensions: { + width: 12, + height: 12, + } + }, + container: { + corner_radius: 4, + padding: { + top: 4, bottom: 4, left: 4, right: 4 + }, + margin: { + right: 4, + }, + } + } + } + + return toggleable({ + state: { + inactive: interactive({ + state: { + default: base_icon("icons/chevron_right_8.svg", foreground(theme.middle, "variant")), + hovered: base_icon("icons/chevron_right_8.svg", foreground(theme.middle, "hovered")), + clicked: base_icon("icons/chevron_right_8.svg", foreground(theme.middle, "active")), + }, + }), + active: interactive({ + state: { + default: base_icon("icons/chevron_down_8.svg", foreground(theme.highest, "variant")), + hovered: base_icon("icons/chevron_down_8.svg", foreground(theme.highest, "hovered")), + clicked: base_icon("icons/chevron_down_8.svg", foreground(theme.highest, "active")), + }, + }), + }, + }) + })(), + } + } +} From fe5db3035f354ac3898661fac3789e489d5c9b22 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 24 Jul 2023 12:14:15 -0700 Subject: [PATCH 002/105] move channels UI code to channels-rpc --- crates/channels/src/channels.rs | 92 +--------------- crates/channels/src/channels_panel.rs | 140 +----------------------- crates/gpui/src/elements/flex.rs | 7 -- crates/theme/src/theme.rs | 54 +-------- styles/src/style_tree/channels_panel.ts | 58 +--------- 5 files changed, 7 insertions(+), 344 deletions(-) diff --git a/crates/channels/src/channels.rs b/crates/channels/src/channels.rs index 8e55441b29fea844757a18b1a85cc14dc8ed6e2c..7560a36015f48e8552825ffdedfb103e498864ec 100644 --- a/crates/channels/src/channels.rs +++ b/crates/channels/src/channels.rs @@ -2,102 +2,12 @@ mod channels_panel; mod channels_panel_settings; pub use channels_panel::*; -use gpui::{AppContext, Entity}; +use gpui::{AppContext}; use std::sync::Arc; use client::Client; pub fn init(client: Arc, cx: &mut AppContext) { - let channels = cx.add_model(|cx| Channels::new(client, cx)); - cx.set_global(channels); channels_panel::init(cx); } - -#[derive(Debug, Clone)] -struct Channel { - id: u64, - name: String, - sub_channels: Vec, - _room: Option<()>, -} - -impl Channel { - fn new(id: u64, name: impl AsRef, members: Vec) -> Channel { - Channel { - name: name.as_ref().to_string(), - id, - sub_channels: members, - _room: None, - } - } - - fn members(&self) -> &[Channel] { - &self.sub_channels - } - - fn name(&self) -> &str { - &self.name - } -} - -struct Channels { - channels: Vec, -} - -impl Channels { - fn channels(&self) -> Vec { - self.channels.clone() - } -} - -enum ChannelEvents {} - -impl Entity for Channels { - type Event = ChannelEvents; -} - -impl Channels { - fn new(_client: Arc, _cx: &mut AppContext) -> Self { - //TODO: Subscribe to channel updates from the server - Channels { - channels: vec![Channel::new( - 0, - "Zed Industries", - vec![ - Channel::new(1, "#general", Vec::new()), - Channel::new(2, "#admiral", Vec::new()), - Channel::new(3, "#livestreaming", vec![]), - Channel::new(4, "#crdb", Vec::new()), - Channel::new(5, "#crdb-1", Vec::new()), - Channel::new(6, "#crdb-2", Vec::new()), - Channel::new(7, "#crdb-3", vec![]), - Channel::new(8, "#crdb-4", Vec::new()), - Channel::new(9, "#crdb-1", Vec::new()), - Channel::new(10, "#crdb-1", Vec::new()), - Channel::new(11, "#crdb-1", Vec::new()), - Channel::new(12, "#crdb-1", vec![]), - Channel::new(13, "#crdb-1", Vec::new()), - Channel::new(14, "#crdb-1", Vec::new()), - Channel::new(15, "#crdb-1", Vec::new()), - Channel::new(16, "#crdb-1", Vec::new()), - Channel::new(17, "#crdb", vec![]), - ], - ), - Channel::new( - 18, - "CRDB Consulting", - vec![ - Channel::new(19, "#crdb 😭", Vec::new()), - Channel::new(20, "#crdb 😌", Vec::new()), - Channel::new(21, "#crdb 🦀", vec![]), - Channel::new(22, "#crdb 😤", Vec::new()), - Channel::new(23, "#crdb 😤", Vec::new()), - Channel::new(24, "#crdb 😤", Vec::new()), - Channel::new(25, "#crdb 😤", vec![]), - Channel::new(26, "#crdb 😤", Vec::new()), - ], - )], - } - } -} diff --git a/crates/channels/src/channels_panel.rs b/crates/channels/src/channels_panel.rs index 73697b3b7249ec19878c482575df926fd16b0545..063f65219181dff18cc38ba6e12cabe5c17d667f 100644 --- a/crates/channels/src/channels_panel.rs +++ b/crates/channels/src/channels_panel.rs @@ -1,23 +1,19 @@ use std::sync::Arc; -use crate::{ - channels_panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}, - Channel, Channels, -}; +use crate::channels_panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}; use anyhow::Result; use collections::HashMap; use context_menu::ContextMenu; use db::kvp::KEY_VALUE_STORE; use gpui::{ actions, - elements::{ChildView, Empty, Flex, Label, MouseEventHandler, ParentElement, Stack}, - serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, Task, View, - ViewContext, ViewHandle, WeakViewHandle, + elements::{ChildView, Flex, Label, ParentElement, Stack}, + serde_json, AppContext, AsyncAppContext, Element, Entity, Task, View, ViewContext, + ViewHandle, WeakViewHandle, }; use project::Fs; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; -use theme::ChannelTreeStyle; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -37,7 +33,6 @@ pub struct ChannelsPanel { fs: Arc, has_focus: bool, pending_serialization: Task>, - channels: ModelHandle, context_menu: ViewHandle, collapsed_channels: HashMap, } @@ -67,7 +62,6 @@ impl ChannelsPanel { has_focus: false, fs: workspace.app_state().fs.clone(), pending_serialization: Task::ready(None), - channels: cx.global::>().clone(), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), collapsed_channels: HashMap::default(), }; @@ -138,101 +132,6 @@ impl ChannelsPanel { .log_err(), ); } - - fn render_channel( - &mut self, - depth: usize, - channel: &Channel, - style: &ChannelTreeStyle, - root: bool, - cx: &mut ViewContext, - ) -> AnyElement { - let has_chilren = !channel.members().is_empty(); - - let sub_channel_details = has_chilren.then(|| { - let mut sub_channels = Flex::column(); - let collapsed = self - .collapsed_channels - .get(&channel.id) - .copied() - .unwrap_or_default(); - if !collapsed { - for sub_channel in channel.members() { - sub_channels = sub_channels.with_child(self.render_channel( - depth + 1, - sub_channel, - style, - false, - cx, - )); - } - } - (sub_channels, collapsed) - }); - - let channel_id = channel.id; - - enum ChannelCollapser {} - Flex::row() - .with_child( - Empty::new() - .constrained() - .with_width(depth as f32 * style.channel_indent), - ) - .with_child( - Flex::column() - .with_child( - Flex::row() - .with_child( - sub_channel_details - .as_ref() - .map(|(_, expanded)| { - MouseEventHandler::::new( - channel.id as usize, - cx, - |state, _cx| { - let icon = - style.channel_icon.style_for(!*expanded, state); - theme::ui::icon(icon) - }, - ) - .on_click( - gpui::platform::MouseButton::Left, - move |_, v, cx| { - let entry = v - .collapsed_channels - .entry(channel_id) - .or_default(); - *entry = !*entry; - v.serialize(cx); - cx.notify(); - }, - ) - .into_any() - }) - .unwrap_or_else(|| { - Empty::new() - .constrained() - .with_width(style.channel_icon.default_style().width()) - .into_any() - }), - ) - .with_child( - Label::new( - channel.name().to_string(), - if root { - style.root_name.clone() - } else { - style.channel_name.clone() - }, - ) - .into_any(), - ), - ) - .with_children(sub_channel_details.map(|(elements, _)| elements)), - ) - .into_any() - } } impl View for ChannelsPanel { @@ -254,42 +153,11 @@ impl View for ChannelsPanel { fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { let theme = theme::current(cx).clone(); - let mut channels_column = Flex::column(); - for channel in self.channels.read(cx).channels() { - channels_column = channels_column.with_child(self.render_channel( - 0, - &channel, - &theme.channels_panel.channel_tree, - true, - cx, - )); - } - - let spacing = theme.channels_panel.spacing; - enum ChannelsPanelScrollTag {} Stack::new() .with_child( // Full panel column Flex::column() - .with_spacing(spacing) - .with_child( - // Channels section column - Flex::column() - .with_child( - Flex::row().with_child( - Label::new( - "Active Channels", - theme.editor.invalid_information_diagnostic.message.clone(), - ) - .into_any(), - ), - ) - // Channels list column - .with_child(channels_column), - ) - // TODO: Replace with spacing implementation - .with_child(Empty::new().constrained().with_height(spacing)) .with_child( Flex::column().with_child( Flex::row().with_child( diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index 40959c8f5c2eb3c57b03b938f80c9e8e1c1eeedc..857f3f56fc08b0b24f39011d7f4323838b97dde2 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/crates/gpui/src/elements/flex.rs @@ -22,7 +22,6 @@ pub struct Flex { children: Vec>, scroll_state: Option<(ElementStateHandle>, usize)>, child_alignment: f32, - spacing: f32, } impl Flex { @@ -32,7 +31,6 @@ impl Flex { children: Default::default(), scroll_state: None, child_alignment: -1., - spacing: 0., } } @@ -44,11 +42,6 @@ impl Flex { Self::new(Axis::Vertical) } - pub fn with_spacing(mut self, spacing: f32) -> Self { - self.spacing = spacing; - self - } - /// Render children centered relative to the cross-axis of the parent flex. /// /// If this is a flex row, children will be centered vertically. If this is a diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 844b093a5eaf5e93a177e83faeff542b9b95d516..56b3b2d1560f41fc43b61b5d38659a2852d65835 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -1068,61 +1068,9 @@ pub struct FlexStyle { item_spacing: f32, } -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ChannelProjectStyle { - // TODO: Implement Contained Flex - // ContainerStyle + Spacing between elements - // Negative spacing overlaps elements instead of spacing them out - pub container: Contained, - pub host: ImageStyle, - pub title: ContainedText, - pub members: Contained, - pub member: ImageStyle -} - -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ChanneltemStyle { - pub icon: IconStyle, - pub title: TextStyle, -} - -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ChannelListStyle { - pub section_title: ContainedText, - pub channel: Toggleable>, - pub project: ChannelProjectStyle -} - -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ContactItemStyle { - pub container: Contained, - pub avatar: IconStyle, - pub name: TextStyle, -} - -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ContactsListStyle { - pub section_title: ContainedText, - pub contact: ContactItemStyle, -} - - -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ChannelTreeStyle { - pub channel_indent: f32, - pub channel_name: TextStyle, - pub root_name: TextStyle, - pub channel_icon: Toggleable>, -} - #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct ChanelsPanelStyle { - pub channel_tree: ChannelTreeStyle, - pub spacing: f32, - // TODO: Uncomment: - // pub container: ContainerStyle, - // pub channel_list: ChannelListStyle, - // pub contacts_list: ContactsListStyle + pub contacts_header: TextStyle, } #[derive(Clone, Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/channels_panel.ts b/styles/src/style_tree/channels_panel.ts index b46db5dc38edd92d396aa1ed8899f1d8e4dbbd6b..126bbbe18cad17f3783c403748c55c852528548a 100644 --- a/styles/src/style_tree/channels_panel.ts +++ b/styles/src/style_tree/channels_panel.ts @@ -1,68 +1,12 @@ -// import { with_opacity } from "../theme/color" import { - // Border, - // TextStyle, - // background, - // border, - foreground, text, } from "./components" -import { interactive, toggleable } from "../element" -// import merge from "ts-deepmerge" import { useTheme } from "../theme" export default function channels_panel(): any { const theme = useTheme() - // const { is_light } = theme return { - spacing: 10, - channel_tree: { - channel_indent: 10, - channel_name: text(theme.middle, "sans", "variant", { size: "md" }), - root_name: text(theme.middle, "sans", "variant", { size: "lg", weight: "bold" }), - channel_icon: (() => { - const base_icon = (asset: any, color: any) => { - return { - icon: { - color, - asset, - dimensions: { - width: 12, - height: 12, - } - }, - container: { - corner_radius: 4, - padding: { - top: 4, bottom: 4, left: 4, right: 4 - }, - margin: { - right: 4, - }, - } - } - } - - return toggleable({ - state: { - inactive: interactive({ - state: { - default: base_icon("icons/chevron_right_8.svg", foreground(theme.middle, "variant")), - hovered: base_icon("icons/chevron_right_8.svg", foreground(theme.middle, "hovered")), - clicked: base_icon("icons/chevron_right_8.svg", foreground(theme.middle, "active")), - }, - }), - active: interactive({ - state: { - default: base_icon("icons/chevron_down_8.svg", foreground(theme.highest, "variant")), - hovered: base_icon("icons/chevron_down_8.svg", foreground(theme.highest, "hovered")), - clicked: base_icon("icons/chevron_down_8.svg", foreground(theme.highest, "active")), - }, - }), - }, - }) - })(), - } + contacts_header: text(theme.middle, "sans", "variant", { size: "lg" }), } } From 7f9df6dd2425a5746fe5a330b40422ef11ffa771 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 24 Jul 2023 14:39:16 -0700 Subject: [PATCH 003/105] Move channels panel into collab and rename to collab panel remove contacts popover and add to collab panel --- Cargo.lock | 28 +--- Cargo.toml | 1 - assets/keymaps/default.json | 3 +- crates/channels/Cargo.toml | 38 ------ crates/channels/src/channels.rs | 13 -- crates/collab_ui/Cargo.toml | 2 + crates/collab_ui/src/collab_titlebar_item.rs | 126 +----------------- crates/collab_ui/src/collab_ui.rs | 10 +- .../src/panel.rs} | 82 +++++++----- .../contacts.rs} | 33 ++--- .../{ => panel/contacts}/contact_finder.rs | 0 .../contacts/contacts_list.rs} | 13 +- .../src/panel/panel_settings.rs} | 0 crates/gpui/src/elements.rs | 8 +- crates/rpc/src/proto.rs | 2 + crates/zed/Cargo.toml | 1 - crates/zed/src/main.rs | 1 - crates/zed/src/zed.rs | 26 +--- 18 files changed, 95 insertions(+), 292 deletions(-) delete mode 100644 crates/channels/Cargo.toml delete mode 100644 crates/channels/src/channels.rs rename crates/{channels/src/channels_panel.rs => collab_ui/src/panel.rs} (79%) rename crates/collab_ui/src/{contacts_popover.rs => panel/contacts.rs} (85%) rename crates/collab_ui/src/{ => panel/contacts}/contact_finder.rs (100%) rename crates/collab_ui/src/{contact_list.rs => panel/contacts/contacts_list.rs} (99%) rename crates/{channels/src/channels_panel_settings.rs => collab_ui/src/panel/panel_settings.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index e0a4b6a7bf8a9231099016f541b7f5c0da2caa5a..617d2c9a81b53e0d2e0570d51d9cf11d6d0d3d81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1254,31 +1254,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" -[[package]] -name = "channels" -version = "0.1.0" -dependencies = [ - "anyhow", - "client", - "collections", - "context_menu", - "db", - "editor", - "futures 0.3.28", - "gpui", - "log", - "menu", - "project", - "schemars", - "serde", - "serde_derive", - "serde_json", - "settings", - "theme", - "util", - "workspace", -] - [[package]] name = "chrono" version = "0.4.26" @@ -1577,6 +1552,7 @@ dependencies = [ "clock", "collections", "context_menu", + "db", "editor", "feedback", "futures 0.3.28", @@ -1588,6 +1564,7 @@ dependencies = [ "postage", "project", "recent_projects", + "schemars", "serde", "serde_derive", "settings", @@ -9882,7 +9859,6 @@ dependencies = [ "backtrace", "breadcrumbs", "call", - "channels", "chrono", "cli", "client", diff --git a/Cargo.toml b/Cargo.toml index 8803d1c34b02be3938870f58c212a06e54e15572..6e79c6b6576c290a4a0e85e56a80cfe9b100ff2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ members = [ "crates/auto_update", "crates/breadcrumbs", "crates/call", - "crates/channels", "crates/cli", "crates/client", "crates/clock", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index adc55f8c91e8cd39ec72745975c988aaaac9fb7c..5c14d818a700bdddbed9ec6ed4d23caf27d654b0 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -499,7 +499,8 @@ { "bindings": { "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator", - "cmd-shift-c": "collab::ToggleContactsMenu", + // TODO: Move this to a dock open action + "cmd-shift-c": "collab_panel::ToggleFocus", "cmd-alt-i": "zed::DebugElements" } }, diff --git a/crates/channels/Cargo.toml b/crates/channels/Cargo.toml deleted file mode 100644 index 75070721306306ee9600d77d67d17aed1710cafe..0000000000000000000000000000000000000000 --- a/crates/channels/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "channels" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -path = "src/channels.rs" -doctest = false - -[dependencies] -collections = { path = "../collections" } -context_menu = { path = "../context_menu" } -client = { path = "../client" } -db = { path = "../db" } -editor = { path = "../editor" } -gpui = { path = "../gpui" } -project = { path = "../project" } -theme = { path = "../theme" } -settings = { path = "../settings" } -workspace = { path = "../workspace" } -menu = { path = "../menu" } -util = { path = "../util" } - -log.workspace = true -anyhow.workspace = true -schemars.workspace = true -serde_json.workspace = true -serde.workspace = true -serde_derive.workspace = true -futures.workspace = true - -[dev-dependencies] -client = { path = "../client", features = ["test-support"] } -editor = { path = "../editor", features = ["test-support"] } -gpui = { path = "../gpui", features = ["test-support"] } -workspace = { path = "../workspace", features = ["test-support"] } -serde_json.workspace = true diff --git a/crates/channels/src/channels.rs b/crates/channels/src/channels.rs deleted file mode 100644 index 7560a36015f48e8552825ffdedfb103e498864ec..0000000000000000000000000000000000000000 --- a/crates/channels/src/channels.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod channels_panel; -mod channels_panel_settings; - -pub use channels_panel::*; -use gpui::{AppContext}; - -use std::sync::Arc; - -use client::Client; - -pub fn init(client: Arc, cx: &mut AppContext) { - channels_panel::init(cx); -} diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 4a38c2691cc602e018d77d9ab89fb88f610433eb..2ceac649ecf9e013349acbb3610fb4f880ea10f7 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -23,6 +23,7 @@ test-support = [ [dependencies] auto_update = { path = "../auto_update" } +db = { path = "../db" } call = { path = "../call" } client = { path = "../client" } clock = { path = "../clock" } @@ -48,6 +49,7 @@ zed-actions = {path = "../zed-actions"} anyhow.workspace = true futures.workspace = true log.workspace = true +schemars.workspace = true postage.workspace = true serde.workspace = true serde_derive.workspace = true diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index a54c0e9e7950634f64fe123cfb560e1e1f1f28dc..0d273fd1b813079e547c094950f7172b78e3a776 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,12 +1,11 @@ use crate::{ - contact_notification::ContactNotification, contacts_popover, face_pile::FacePile, + contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing, }; use call::{ActiveCall, ParticipantLocation, Room}; use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore}; use clock::ReplicaId; -use contacts_popover::ContactsPopover; use context_menu::{ContextMenu, ContextMenuItem}; use gpui::{ actions, @@ -33,7 +32,6 @@ const MAX_BRANCH_NAME_LENGTH: usize = 40; actions!( collab, [ - ToggleContactsMenu, ToggleUserMenu, ToggleProjectMenu, SwitchBranch, @@ -43,7 +41,6 @@ actions!( ); pub fn init(cx: &mut AppContext) { - cx.add_action(CollabTitlebarItem::toggle_contacts_popover); cx.add_action(CollabTitlebarItem::share_project); cx.add_action(CollabTitlebarItem::unshare_project); cx.add_action(CollabTitlebarItem::toggle_user_menu); @@ -56,7 +53,6 @@ pub struct CollabTitlebarItem { user_store: ModelHandle, client: Arc, workspace: WeakViewHandle, - contacts_popover: Option>, branch_popover: Option>, project_popover: Option>, user_menu: ViewHandle, @@ -109,7 +105,6 @@ impl View for CollabTitlebarItem { let status = workspace.read(cx).client().status(); let status = &*status.borrow(); if matches!(status, client::Status::Connected { .. }) { - right_container.add_child(self.render_toggle_contacts_button(&theme, cx)); let avatar = user.as_ref().and_then(|user| user.avatar.clone()); right_container.add_child(self.render_user_menu_button(&theme, avatar, cx)); } else { @@ -184,7 +179,6 @@ impl CollabTitlebarItem { project, user_store, client, - contacts_popover: None, user_menu: cx.add_view(|cx| { let view_id = cx.view_id(); let mut menu = ContextMenu::new(view_id, cx); @@ -315,9 +309,6 @@ impl CollabTitlebarItem { } fn active_call_changed(&mut self, cx: &mut ViewContext) { - if ActiveCall::global(cx).read(cx).room().is_none() { - self.contacts_popover = None; - } cx.notify(); } @@ -337,32 +328,6 @@ impl CollabTitlebarItem { .log_err(); } - pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext) { - if self.contacts_popover.take().is_none() { - let view = cx.add_view(|cx| { - ContactsPopover::new( - self.project.clone(), - self.user_store.clone(), - self.workspace.clone(), - cx, - ) - }); - cx.subscribe(&view, |this, _, event, cx| { - match event { - contacts_popover::Event::Dismissed => { - this.contacts_popover = None; - } - } - - cx.notify(); - }) - .detach(); - self.contacts_popover = Some(view); - } - - cx.notify(); - } - pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext) { self.user_menu.update(cx, |user_menu, cx| { let items = if let Some(_) = self.user_store.read(cx).current_user() { @@ -519,79 +484,7 @@ impl CollabTitlebarItem { } cx.notify(); } - fn render_toggle_contacts_button( - &self, - theme: &Theme, - cx: &mut ViewContext, - ) -> AnyElement { - let titlebar = &theme.titlebar; - - let badge = if self - .user_store - .read(cx) - .incoming_contact_requests() - .is_empty() - { - None - } else { - Some( - Empty::new() - .collapsed() - .contained() - .with_style(titlebar.toggle_contacts_badge) - .contained() - .with_margin_left( - titlebar - .toggle_contacts_button - .inactive_state() - .default - .icon_width, - ) - .with_margin_top( - titlebar - .toggle_contacts_button - .inactive_state() - .default - .icon_width, - ) - .aligned(), - ) - }; - Stack::new() - .with_child( - MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar - .toggle_contacts_button - .in_state(self.contacts_popover.is_some()) - .style_for(state); - Svg::new("icons/radix/person.svg") - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - .contained() - .with_style(style.container) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.toggle_contacts_popover(&Default::default(), cx) - }) - .with_tooltip::( - 0, - "Show contacts menu".into(), - Some(Box::new(ToggleContactsMenu)), - theme.tooltip.clone(), - cx, - ), - ) - .with_children(badge) - .with_children(self.render_contacts_popover_host(titlebar, cx)) - .into_any() - } fn render_toggle_screen_sharing_button( &self, theme: &Theme, @@ -923,23 +816,6 @@ impl CollabTitlebarItem { .into_any() } - fn render_contacts_popover_host<'a>( - &'a self, - _theme: &'a theme::Titlebar, - cx: &'a ViewContext, - ) -> Option> { - self.contacts_popover.as_ref().map(|popover| { - Overlay::new(ChildView::new(popover, cx)) - .with_fit_mode(OverlayFitMode::SwitchAnchor) - .with_anchor_corner(AnchorCorner::TopLeft) - .with_z_index(999) - .aligned() - .bottom() - .right() - .into_any() - }) - } - fn render_collaborators( &self, workspace: &ViewHandle, diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index df4b502391a3830aec28c817983f98b7dad7643c..edbb89e33955d00e29432f8f0509d778be3ef172 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,16 +1,14 @@ mod collab_titlebar_item; -mod contact_finder; -mod contact_list; mod contact_notification; -mod contacts_popover; mod face_pile; mod incoming_call_notification; mod notifications; mod project_shared_notification; mod sharing_status_indicator; +pub mod panel; use call::{ActiveCall, Room}; -pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu}; +pub use collab_titlebar_item::CollabTitlebarItem; use gpui::{actions, AppContext, Task}; use std::sync::Arc; use util::ResultExt; @@ -24,9 +22,7 @@ actions!( pub fn init(app_state: &Arc, cx: &mut AppContext) { vcs_menu::init(cx); collab_titlebar_item::init(cx); - contact_list::init(cx); - contact_finder::init(cx); - contacts_popover::init(cx); + panel::init(app_state.client.clone(), cx); incoming_call_notification::init(&app_state, cx); project_shared_notification::init(&app_state, cx); sharing_status_indicator::init(cx); diff --git a/crates/channels/src/channels_panel.rs b/crates/collab_ui/src/panel.rs similarity index 79% rename from crates/channels/src/channels_panel.rs rename to crates/collab_ui/src/panel.rs index 063f65219181dff18cc38ba6e12cabe5c17d667f..8fec29133ff7e0c5115b9e91860aa8ce5ceb24eb 100644 --- a/crates/channels/src/channels_panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -1,15 +1,17 @@ +mod contacts; +mod panel_settings; + use std::sync::Arc; -use crate::channels_panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}; use anyhow::Result; -use collections::HashMap; +use client::Client; use context_menu::ContextMenu; use db::kvp::KEY_VALUE_STORE; use gpui::{ actions, elements::{ChildView, Flex, Label, ParentElement, Stack}, - serde_json, AppContext, AsyncAppContext, Element, Entity, Task, View, ViewContext, - ViewHandle, WeakViewHandle, + serde_json, AppContext, AsyncAppContext, Element, Entity, Task, View, ViewContext, ViewHandle, + WeakViewHandle, }; use project::Fs; use serde_derive::{Deserialize, Serialize}; @@ -20,27 +22,32 @@ use workspace::{ Workspace, }; -actions!(channels, [ToggleFocus]); +use self::{ + contacts::Contacts, + panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}, +}; + +actions!(collab_panel, [ToggleFocus]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; -pub fn init(cx: &mut AppContext) { - settings::register::(cx); +pub fn init(_client: Arc, cx: &mut AppContext) { + settings::register::(cx); + contacts::init(cx); } -pub struct ChannelsPanel { +pub struct CollabPanel { width: Option, fs: Arc, has_focus: bool, pending_serialization: Task>, context_menu: ViewHandle, - collapsed_channels: HashMap, + contacts: ViewHandle, } #[derive(Serialize, Deserialize)] struct SerializedChannelsPanel { width: Option, - collapsed_channels: Option>, } #[derive(Debug)] @@ -49,26 +56,34 @@ pub enum Event { Focus, } -impl Entity for ChannelsPanel { +impl Entity for CollabPanel { type Event = Event; } -impl ChannelsPanel { +impl CollabPanel { pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { cx.add_view(|cx| { let view_id = cx.view_id(); + let this = Self { width: None, has_focus: false, fs: workspace.app_state().fs.clone(), pending_serialization: Task::ready(None), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), - collapsed_channels: HashMap::default(), + contacts: cx.add_view(|cx| { + Contacts::new( + workspace.project().clone(), + workspace.user_store().clone(), + workspace.weak_handle(), + cx, + ) + }), }; // Update the dock position when the setting changes. let mut old_dock_position = this.position(cx); - cx.observe_global::(move |this: &mut ChannelsPanel, cx| { + cx.observe_global::(move |this: &mut CollabPanel, cx| { let new_dock_position = this.position(cx); if new_dock_position != old_dock_position { old_dock_position = new_dock_position; @@ -99,12 +114,10 @@ impl ChannelsPanel { }; workspace.update(&mut cx, |workspace, cx| { - let panel = ChannelsPanel::new(workspace, cx); + let panel = CollabPanel::new(workspace, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width; - panel.collapsed_channels = - serialized_panel.collapsed_channels.unwrap_or_default(); cx.notify(); }); } @@ -115,16 +128,12 @@ impl ChannelsPanel { fn serialize(&mut self, cx: &mut ViewContext) { let width = self.width; - let collapsed_channels = self.collapsed_channels.clone(); self.pending_serialization = cx.background().spawn( async move { KEY_VALUE_STORE .write_kvp( CHANNELS_PANEL_KEY.into(), - serde_json::to_string(&SerializedChannelsPanel { - width, - collapsed_channels: Some(collapsed_channels), - })?, + serde_json::to_string(&SerializedChannelsPanel { width })?, ) .await?; anyhow::Ok(()) @@ -134,7 +143,7 @@ impl ChannelsPanel { } } -impl View for ChannelsPanel { +impl View for CollabPanel { fn ui_name() -> &'static str { "ChannelsPanel" } @@ -159,18 +168,19 @@ impl View for ChannelsPanel { // Full panel column Flex::column() .with_child( - Flex::column().with_child( - Flex::row().with_child( - Label::new( - "Contacts", - theme.editor.invalid_information_diagnostic.message.clone(), - ) - .into_any(), - ), - ), + Flex::column() + .with_child( + Flex::row().with_child( + Label::new( + "Contacts", + theme.editor.invalid_information_diagnostic.message.clone(), + ) + .into_any(), + ), + ) + .with_child(ChildView::new(&self.contacts, cx)), ) - .scrollable::(0, None, cx) - .expanded(), + .scrollable::(0, None, cx), ) .with_child(ChildView::new(&self.context_menu, cx)) .into_any_named("channels panel") @@ -178,7 +188,7 @@ impl View for ChannelsPanel { } } -impl Panel for ChannelsPanel { +impl Panel for CollabPanel { fn position(&self, cx: &gpui::WindowContext) -> DockPosition { match settings::get::(cx).dock { ChannelsPanelDockPosition::Left => DockPosition::Left, @@ -216,7 +226,7 @@ impl Panel for ChannelsPanel { } fn icon_path(&self) -> &'static str { - "icons/bolt_16.svg" + "icons/radix/person.svg" } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/collab_ui/src/contacts_popover.rs b/crates/collab_ui/src/panel/contacts.rs similarity index 85% rename from crates/collab_ui/src/contacts_popover.rs rename to crates/collab_ui/src/panel/contacts.rs index 1d6d1c84c7353faf85556f621fdc8d1b372b444a..a1c1061f5e0845c37370580df608121b6db1aa29 100644 --- a/crates/collab_ui/src/contacts_popover.rs +++ b/crates/collab_ui/src/panel/contacts.rs @@ -1,7 +1,6 @@ -use crate::{ - contact_finder::{build_contact_finder, ContactFinder}, - contact_list::ContactList, -}; +mod contact_finder; +mod contacts_list; + use client::UserStore; use gpui::{ actions, elements::*, platform::MouseButton, AppContext, Entity, ModelHandle, View, @@ -11,10 +10,14 @@ use picker::PickerEvent; use project::Project; use workspace::Workspace; +use self::{contacts_list::ContactList, contact_finder::{ContactFinder, build_contact_finder}}; + actions!(contacts_popover, [ToggleContactFinder]); pub fn init(cx: &mut AppContext) { - cx.add_action(ContactsPopover::toggle_contact_finder); + cx.add_action(Contacts::toggle_contact_finder); + contact_finder::init(cx); + contacts_list::init(cx); } pub enum Event { @@ -26,7 +29,7 @@ enum Child { ContactFinder(ViewHandle), } -pub struct ContactsPopover { +pub struct Contacts { child: Child, project: ModelHandle, user_store: ModelHandle, @@ -34,7 +37,7 @@ pub struct ContactsPopover { _subscription: Option, } -impl ContactsPopover { +impl Contacts { pub fn new( project: ModelHandle, user_store: ModelHandle, @@ -61,7 +64,7 @@ impl ContactsPopover { } } - fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext) { + fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext) { let child = cx.add_view(|cx| { let finder = build_contact_finder(self.user_store.clone(), cx); finder.set_query(editor_text, cx); @@ -75,7 +78,7 @@ impl ContactsPopover { cx.notify(); } - fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext) { + fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext) { let child = cx.add_view(|cx| { ContactList::new( self.project.clone(), @@ -87,8 +90,8 @@ impl ContactsPopover { }); cx.focus(&child); self._subscription = Some(cx.subscribe(&child, |this, _, event, cx| match event { - crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed), - crate::contact_list::Event::ToggleContactFinder => { + contacts_list::Event::Dismissed => cx.emit(Event::Dismissed), + contacts_list::Event::ToggleContactFinder => { this.toggle_contact_finder(&Default::default(), cx) } })); @@ -97,11 +100,11 @@ impl ContactsPopover { } } -impl Entity for ContactsPopover { +impl Entity for Contacts { type Event = Event; } -impl View for ContactsPopover { +impl View for Contacts { fn ui_name() -> &'static str { "ContactsPopover" } @@ -113,9 +116,9 @@ impl View for ContactsPopover { Child::ContactFinder(child) => ChildView::new(child, cx), }; - MouseEventHandler::::new(0, cx, |_, _| { + MouseEventHandler::::new(0, cx, |_, _| { Flex::column() - .with_child(child.flex(1., true)) + .with_child(child) .contained() .with_style(theme.contacts_popover.container) .constrained() diff --git a/crates/collab_ui/src/contact_finder.rs b/crates/collab_ui/src/panel/contacts/contact_finder.rs similarity index 100% rename from crates/collab_ui/src/contact_finder.rs rename to crates/collab_ui/src/panel/contacts/contact_finder.rs diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/panel/contacts/contacts_list.rs similarity index 99% rename from crates/collab_ui/src/contact_list.rs rename to crates/collab_ui/src/panel/contacts/contacts_list.rs index 428f2156d116133d063710fed99c890e8ad30869..f37d64cd05170a4e9992d8789f7642c0ec8a7715 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/crates/collab_ui/src/panel/contacts/contacts_list.rs @@ -1326,12 +1326,11 @@ impl View for ContactList { Flex::column() .with_child( Flex::row() - .with_child( - ChildView::new(&self.filter_editor, cx) - .contained() - .with_style(theme.contact_list.user_query_editor.container) - .flex(1., true), - ) + // .with_child( + // ChildView::new(&self.filter_editor, cx) + // .contained() + // .with_style(theme.contact_list.user_query_editor.container) + // ) .with_child( MouseEventHandler::::new(0, cx, |_, _| { render_icon_button( @@ -1354,7 +1353,7 @@ impl View for ContactList { .constrained() .with_height(theme.contact_list.user_query_editor_height), ) - .with_child(List::new(self.list_state.clone()).flex(1., false)) + // .with_child(List::new(self.list_state.clone())) .into_any() } diff --git a/crates/channels/src/channels_panel_settings.rs b/crates/collab_ui/src/panel/panel_settings.rs similarity index 100% rename from crates/channels/src/channels_panel_settings.rs rename to crates/collab_ui/src/panel/panel_settings.rs diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index 78403444fff1807ee7b01ef8fe6a8f8493edfab2..746238aaa99b248b0cc8606b9ee87ebd40871f84 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -47,6 +47,10 @@ pub trait Element: 'static { type LayoutState; type PaintState; + fn view_name(&self) -> &'static str { + V::ui_name() + } + fn layout( &mut self, constraint: SizeConstraint, @@ -267,8 +271,8 @@ impl> AnyElementState for ElementState { | ElementState::PostLayout { mut element, .. } | ElementState::PostPaint { mut element, .. } => { let (size, layout) = element.layout(constraint, view, cx); - debug_assert!(size.x().is_finite()); - debug_assert!(size.y().is_finite()); + debug_assert!(size.x().is_finite(), "Element for {:?} had infinite x size after layout", element.view_name()); + debug_assert!(size.y().is_finite(), "Element for {:?} had infinite x size after layout", element.view_name()); result = size; ElementState::PostLayout { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 605b05a56285a2f2c18c001136898adeb1e75d97..e24d6cb4b74b407f4ab15083a7a257fb17e352ac 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -1,3 +1,5 @@ +#![allow(non_snake_case)] + use super::{entity_messages, messages, request_messages, ConnectionId, TypedEnvelope}; use anyhow::{anyhow, Result}; use async_tungstenite::tungstenite::Message as WebSocketMessage; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 71d8461b01316b2382183afb3fba0a096d90b8b6..a5877aaccb41a390776a16d8627653d2c0afa40c 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -21,7 +21,6 @@ activity_indicator = { path = "../activity_indicator" } auto_update = { path = "../auto_update" } breadcrumbs = { path = "../breadcrumbs" } call = { path = "../call" } -channels = { path = "../channels" } cli = { path = "../cli" } collab_ui = { path = "../collab_ui" } collections = { path = "../collections" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 5739052b675bcc663f37beb1e10900a0bfe94de4..e44ab3e33acbb213f69b230a80075aea07890c85 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -155,7 +155,6 @@ fn main() { outline::init(cx); project_symbols::init(cx); project_panel::init(Assets, cx); - channels::init(client.clone(), cx); diagnostics::init(cx); search::init(cx); semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c1046c09958f6ab0e92819ceaf1b7d1bfbfbe627..b2d1c2a7a2e468ec4fd533868cc5a47d5b837652 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -9,9 +9,8 @@ use ai::AssistantPanel; use anyhow::Context; use assets::Assets; use breadcrumbs::Breadcrumbs; -use channels::ChannelsPanel; pub use client; -use collab_ui::{CollabTitlebarItem, ToggleContactsMenu}; +use collab_ui::CollabTitlebarItem; // TODO: Add back toggle collab ui shortcut use collections::VecDeque; pub use editor; use editor::{Editor, MultiBuffer}; @@ -86,20 +85,6 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { cx.toggle_full_screen(); }, ); - cx.add_action( - |workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext| { - if let Some(item) = workspace - .titlebar_item() - .and_then(|item| item.downcast::()) - { - cx.defer(move |_, cx| { - item.update(cx, |item, cx| { - item.toggle_contacts_popover(&Default::default(), cx); - }); - }); - } - }, - ); cx.add_global_action(quit); cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url)); cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| { @@ -223,8 +208,10 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { }, ); cx.add_action( - |workspace: &mut Workspace, _: &channels::ToggleFocus, cx: &mut ViewContext| { - workspace.toggle_panel_focus::(cx); + |workspace: &mut Workspace, + _: &collab_ui::panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); }, ); cx.add_action( @@ -345,7 +332,8 @@ pub fn initialize_workspace( let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); - let channels_panel = ChannelsPanel::load(workspace_handle.clone(), cx.clone()); + let channels_panel = + collab_ui::panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!( project_panel, terminal_panel, From 969ecfcfa234ee150eebb87507cebec48afdf53f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 24 Jul 2023 20:00:31 -0700 Subject: [PATCH 004/105] Reinstate all of the contacts popovers' functionality in the new collaboration panel --- crates/collab_ui/src/panel.rs | 1375 +++++++++++++++- .../panel/{contacts => }/contact_finder.rs | 0 crates/collab_ui/src/panel/contacts.rs | 140 -- .../src/panel/contacts/contacts_list.rs | 1384 ----------------- crates/gpui/src/elements.rs | 12 +- styles/src/style_tree/contacts_popover.ts | 6 +- 6 files changed, 1341 insertions(+), 1576 deletions(-) rename crates/collab_ui/src/panel/{contacts => }/contact_finder.rs (100%) delete mode 100644 crates/collab_ui/src/panel/contacts.rs delete mode 100644 crates/collab_ui/src/panel/contacts/contacts_list.rs diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 8fec29133ff7e0c5115b9e91860aa8ce5ceb24eb..28cb57cf7942ab2e095f85e11d37ce9d9e09f5c0 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -1,39 +1,51 @@ -mod contacts; +mod contact_finder; mod panel_settings; -use std::sync::Arc; - use anyhow::Result; -use client::Client; +use call::ActiveCall; +use client::{proto::PeerId, Client, Contact, User, UserStore}; +use contact_finder::{build_contact_finder, ContactFinder}; use context_menu::ContextMenu; use db::kvp::KEY_VALUE_STORE; +use editor::{Cancel, Editor}; +use futures::StreamExt; +use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, - elements::{ChildView, Flex, Label, ParentElement, Stack}, - serde_json, AppContext, AsyncAppContext, Element, Entity, Task, View, ViewContext, ViewHandle, - WeakViewHandle, + elements::{ + Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState, + MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg, + }, + geometry::{rect::RectF, vector::vec2f}, + platform::{CursorStyle, MouseButton, PromptLevel}, + serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, + Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; -use project::Fs; +use menu::{Confirm, SelectNext, SelectPrev}; +use panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}; +use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; +use std::{mem, sync::Arc}; +use theme::IconButton; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, Workspace, }; -use self::{ - contacts::Contacts, - panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}, -}; - actions!(collab_panel, [ToggleFocus]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; pub fn init(_client: Arc, cx: &mut AppContext) { settings::register::(cx); - contacts::init(cx); + contact_finder::init(cx); + + cx.add_action(CollabPanel::cancel); + cx.add_action(CollabPanel::select_next); + cx.add_action(CollabPanel::select_prev); + cx.add_action(CollabPanel::confirm); } pub struct CollabPanel { @@ -42,7 +54,19 @@ pub struct CollabPanel { has_focus: bool, pending_serialization: Task>, context_menu: ViewHandle, - contacts: ViewHandle, + contact_finder: Option>, + + // from contacts list + filter_editor: ViewHandle, + entries: Vec, + selection: Option, + user_store: ModelHandle, + project: ModelHandle, + match_candidates: Vec, + list_state: ListState, + subscriptions: Vec, + collapsed_sections: Vec
, + workspace: WeakViewHandle, } #[derive(Serialize, Deserialize)] @@ -54,6 +78,40 @@ struct SerializedChannelsPanel { pub enum Event { DockPositionChanged, Focus, + Dismissed, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] +enum Section { + ActiveCall, + Requests, + Online, + Offline, +} + +#[derive(Clone)] +enum ContactEntry { + Header(Section), + CallParticipant { + user: Arc, + is_pending: bool, + }, + ParticipantProject { + project_id: u64, + worktree_root_names: Vec, + host_user_id: u64, + is_last: bool, + }, + ParticipantScreen { + peer_id: PeerId, + is_last: bool, + }, + IncomingRequest(Arc), + OutgoingRequest(Arc), + Contact { + contact: Arc, + calling: bool, + }, } impl Entity for CollabPanel { @@ -62,35 +120,151 @@ impl Entity for CollabPanel { impl CollabPanel { pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { - cx.add_view(|cx| { + cx.add_view::(|cx| { let view_id = cx.view_id(); - let this = Self { + let filter_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| { + theme.contact_list.user_query_editor.clone() + })), + cx, + ); + editor.set_placeholder_text("Filter contacts", cx); + editor + }); + + cx.subscribe(&filter_editor, |this, _, event, cx| { + if let editor::Event::BufferEdited = event { + let query = this.filter_editor.read(cx).text(cx); + if !query.is_empty() { + this.selection.take(); + } + this.update_entries(cx); + if !query.is_empty() { + this.selection = this + .entries + .iter() + .position(|entry| !matches!(entry, ContactEntry::Header(_))); + } + } + }) + .detach(); + + let list_state = + ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { + let theme = theme::current(cx).clone(); + let is_selected = this.selection == Some(ix); + let current_project_id = this.project.read(cx).remote_id(); + + match &this.entries[ix] { + ContactEntry::Header(section) => { + let is_collapsed = this.collapsed_sections.contains(section); + Self::render_header( + *section, + &theme.contact_list, + is_selected, + is_collapsed, + cx, + ) + } + ContactEntry::CallParticipant { user, is_pending } => { + Self::render_call_participant( + user, + *is_pending, + is_selected, + &theme.contact_list, + ) + } + ContactEntry::ParticipantProject { + project_id, + worktree_root_names, + host_user_id, + is_last, + } => Self::render_participant_project( + *project_id, + worktree_root_names, + *host_user_id, + Some(*project_id) == current_project_id, + *is_last, + is_selected, + &theme.contact_list, + cx, + ), + ContactEntry::ParticipantScreen { peer_id, is_last } => { + Self::render_participant_screen( + *peer_id, + *is_last, + is_selected, + &theme.contact_list, + cx, + ) + } + ContactEntry::IncomingRequest(user) => Self::render_contact_request( + user.clone(), + this.user_store.clone(), + &theme.contact_list, + true, + is_selected, + cx, + ), + ContactEntry::OutgoingRequest(user) => Self::render_contact_request( + user.clone(), + this.user_store.clone(), + &theme.contact_list, + false, + is_selected, + cx, + ), + ContactEntry::Contact { contact, calling } => Self::render_contact( + contact, + *calling, + &this.project, + &theme.contact_list, + is_selected, + cx, + ), + } + }); + + let mut this = Self { width: None, has_focus: false, fs: workspace.app_state().fs.clone(), pending_serialization: Task::ready(None), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), - contacts: cx.add_view(|cx| { - Contacts::new( - workspace.project().clone(), - workspace.user_store().clone(), - workspace.weak_handle(), - cx, - ) - }), + filter_editor, + contact_finder: None, + entries: Vec::default(), + selection: None, + user_store: workspace.user_store().clone(), + project: workspace.project().clone(), + subscriptions: Vec::default(), + match_candidates: Vec::default(), + collapsed_sections: Vec::default(), + workspace: workspace.weak_handle(), + list_state, }; + this.update_entries(cx); // Update the dock position when the setting changes. let mut old_dock_position = this.position(cx); - cx.observe_global::(move |this: &mut CollabPanel, 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); - } - }) - .detach(); + this.subscriptions + .push( + cx.observe_global::(move |this: &mut CollabPanel, 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); + } + }), + ); + + let active_call = ActiveCall::global(cx); + this.subscriptions + .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx))); + this.subscriptions + .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx))); this }) @@ -141,11 +315,1015 @@ impl CollabPanel { .log_err(), ); } + + fn update_entries(&mut self, cx: &mut ViewContext) { + let user_store = self.user_store.read(cx); + let query = self.filter_editor.read(cx).text(cx); + let executor = cx.background().clone(); + + let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); + let old_entries = mem::take(&mut self.entries); + + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let room = room.read(cx); + let mut participant_entries = Vec::new(); + + // Populate the active user. + if let Some(user) = user_store.current_user() { + self.match_candidates.clear(); + self.match_candidates.push(StringMatchCandidate { + id: 0, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + let user_id = user.id; + participant_entries.push(ContactEntry::CallParticipant { + user, + is_pending: false, + }); + let mut projects = room.local_participant().projects.iter().peekable(); + while let Some(project) = projects.next() { + participant_entries.push(ContactEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: user_id, + is_last: projects.peek().is_none(), + }); + } + } + } + + // Populate remote participants. + self.match_candidates.clear(); + self.match_candidates + .extend(room.remote_participants().iter().map(|(_, participant)| { + StringMatchCandidate { + id: participant.user.id as usize, + string: participant.user.github_login.clone(), + char_bag: participant.user.github_login.chars().collect(), + } + })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + for mat in matches { + let user_id = mat.candidate_id as u64; + let participant = &room.remote_participants()[&user_id]; + participant_entries.push(ContactEntry::CallParticipant { + user: participant.user.clone(), + is_pending: false, + }); + let mut projects = participant.projects.iter().peekable(); + while let Some(project) = projects.next() { + participant_entries.push(ContactEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: participant.user.id, + is_last: projects.peek().is_none() && participant.video_tracks.is_empty(), + }); + } + if !participant.video_tracks.is_empty() { + participant_entries.push(ContactEntry::ParticipantScreen { + peer_id: participant.peer_id, + is_last: true, + }); + } + } + + // Populate pending participants. + self.match_candidates.clear(); + self.match_candidates + .extend( + room.pending_participants() + .iter() + .enumerate() + .map(|(id, participant)| StringMatchCandidate { + id, + string: participant.github_login.clone(), + char_bag: participant.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant { + user: room.pending_participants()[mat.candidate_id].clone(), + is_pending: true, + })); + + if !participant_entries.is_empty() { + self.entries.push(ContactEntry::Header(Section::ActiveCall)); + if !self.collapsed_sections.contains(&Section::ActiveCall) { + self.entries.extend(participant_entries); + } + } + } + + let mut request_entries = Vec::new(); + let incoming = user_store.incoming_contact_requests(); + if !incoming.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + incoming + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), + ); + } + + let outgoing = user_store.outgoing_contact_requests(); + if !outgoing.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + outgoing + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), + ); + } + + if !request_entries.is_empty() { + self.entries.push(ContactEntry::Header(Section::Requests)); + if !self.collapsed_sections.contains(&Section::Requests) { + self.entries.append(&mut request_entries); + } + } + + let contacts = user_store.contacts(); + if !contacts.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + contacts + .iter() + .enumerate() + .map(|(ix, contact)| StringMatchCandidate { + id: ix, + string: contact.user.github_login.clone(), + char_bag: contact.user.github_login.chars().collect(), + }), + ); + + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + + let (mut online_contacts, offline_contacts) = matches + .iter() + .partition::, _>(|mat| contacts[mat.candidate_id].online); + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let room = room.read(cx); + online_contacts.retain(|contact| { + let contact = &contacts[contact.candidate_id]; + !room.contains_participant(contact.user.id) + }); + } + + for (matches, section) in [ + (online_contacts, Section::Online), + (offline_contacts, Section::Offline), + ] { + if !matches.is_empty() { + self.entries.push(ContactEntry::Header(section)); + if !self.collapsed_sections.contains(§ion) { + let active_call = &ActiveCall::global(cx).read(cx); + for mat in matches { + let contact = &contacts[mat.candidate_id]; + self.entries.push(ContactEntry::Contact { + contact: contact.clone(), + calling: active_call.pending_invites().contains(&contact.user.id), + }); + } + } + } + } + } + + if let Some(prev_selected_entry) = prev_selected_entry { + self.selection.take(); + for (ix, entry) in self.entries.iter().enumerate() { + if *entry == prev_selected_entry { + self.selection = Some(ix); + break; + } + } + } + + let old_scroll_top = self.list_state.logical_scroll_top(); + self.list_state.reset(self.entries.len()); + + // Attempt to maintain the same scroll position. + if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) { + let new_scroll_top = self + .entries + .iter() + .position(|entry| entry == old_top_entry) + .map(|item_ix| ListOffset { + item_ix, + offset_in_item: old_scroll_top.offset_in_item, + }) + .or_else(|| { + let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?; + let item_ix = self + .entries + .iter() + .position(|entry| entry == entry_after_old_top)?; + Some(ListOffset { + item_ix, + offset_in_item: 0., + }) + }) + .or_else(|| { + let entry_before_old_top = + old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?; + let item_ix = self + .entries + .iter() + .position(|entry| entry == entry_before_old_top)?; + Some(ListOffset { + item_ix, + offset_in_item: 0., + }) + }); + + self.list_state + .scroll_to(new_scroll_top.unwrap_or(old_scroll_top)); + } + + cx.notify(); + } + + fn render_call_participant( + user: &User, + is_pending: bool, + is_selected: bool, + theme: &theme::ContactList, + ) -> AnyElement { + 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(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true), + ) + .with_children(if is_pending { + Some( + Label::new("Calling", theme.calling_indicator.text.clone()) + .contained() + .with_style(theme.calling_indicator.container) + .aligned(), + ) + } else { + None + }) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .in_state(is_selected) + .style_for(&mut Default::default()), + ) + .into_any() + } + + fn render_participant_project( + project_id: u64, + worktree_root_names: &[String], + host_user_id: u64, + is_current: bool, + is_last: bool, + is_selected: bool, + theme: &theme::ContactList, + cx: &mut ViewContext, + ) -> AnyElement { + enum JoinProject {} + + let font_cache = cx.font_cache(); + let host_avatar_height = theme + .contact_avatar + .width + .or(theme.contact_avatar.height) + .unwrap_or(0.); + let row = &theme.project_row.inactive_state().default; + let tree_branch = theme.tree_branch; + let line_height = row.name.text.line_height(font_cache); + let cap_height = row.name.text.cap_height(font_cache); + let baseline_offset = + row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.; + let project_name = if worktree_root_names.is_empty() { + "untitled".to_string() + } else { + worktree_root_names.join(", ") + }; + + MouseEventHandler::::new(project_id as usize, cx, |mouse_state, _| { + let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + let row = theme + .project_row + .in_state(is_selected) + .style_for(mouse_state); + + Flex::row() + .with_child( + Stack::new() + .with_child(Canvas::new(move |scene, bounds, _, _, _| { + let start_x = + bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.); + let end_x = bounds.max_x(); + let start_y = bounds.min_y(); + let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); + + scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, start_y), + vec2f( + start_x + tree_branch.width, + if is_last { end_y } else { bounds.max_y() }, + ), + ), + background: Some(tree_branch.color), + border: gpui::Border::default(), + corner_radius: 0., + }); + scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, end_y), + vec2f(end_x, end_y + tree_branch.width), + ), + background: Some(tree_branch.color), + border: gpui::Border::default(), + corner_radius: 0., + }); + })) + .constrained() + .with_width(host_avatar_height), + ) + .with_child( + Label::new(project_name, row.name.text.clone()) + .aligned() + .left() + .contained() + .with_style(row.name.container) + .flex(1., false), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style(row.container) + }) + .with_cursor_style(if !is_current { + CursorStyle::PointingHand + } else { + CursorStyle::Arrow + }) + .on_click(MouseButton::Left, move |_, this, cx| { + if !is_current { + if let Some(workspace) = this.workspace.upgrade(cx) { + let app_state = workspace.read(cx).app_state().clone(); + workspace::join_remote_project(project_id, host_user_id, app_state, cx) + .detach_and_log_err(cx); + } + } + }) + .into_any() + } + + fn render_participant_screen( + peer_id: PeerId, + is_last: bool, + is_selected: bool, + theme: &theme::ContactList, + cx: &mut ViewContext, + ) -> AnyElement { + enum OpenSharedScreen {} + + let font_cache = cx.font_cache(); + let host_avatar_height = theme + .contact_avatar + .width + .or(theme.contact_avatar.height) + .unwrap_or(0.); + let row = &theme.project_row.inactive_state().default; + let tree_branch = theme.tree_branch; + let line_height = row.name.text.line_height(font_cache); + let cap_height = row.name.text.cap_height(font_cache); + let baseline_offset = + row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.; + + MouseEventHandler::::new( + peer_id.as_u64() as usize, + cx, + |mouse_state, _| { + let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + let row = theme + .project_row + .in_state(is_selected) + .style_for(mouse_state); + + Flex::row() + .with_child( + Stack::new() + .with_child(Canvas::new(move |scene, bounds, _, _, _| { + let start_x = bounds.min_x() + (bounds.width() / 2.) + - (tree_branch.width / 2.); + let end_x = bounds.max_x(); + let start_y = bounds.min_y(); + let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); + + scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, start_y), + vec2f( + start_x + tree_branch.width, + if is_last { end_y } else { bounds.max_y() }, + ), + ), + background: Some(tree_branch.color), + border: gpui::Border::default(), + corner_radius: 0., + }); + scene.push_quad(gpui::Quad { + bounds: RectF::from_points( + vec2f(start_x, end_y), + vec2f(end_x, end_y + tree_branch.width), + ), + background: Some(tree_branch.color), + border: gpui::Border::default(), + corner_radius: 0., + }); + })) + .constrained() + .with_width(host_avatar_height), + ) + .with_child( + Svg::new("icons/disable_screen_sharing_12.svg") + .with_color(row.icon.color) + .constrained() + .with_width(row.icon.width) + .aligned() + .left() + .contained() + .with_style(row.icon.container), + ) + .with_child( + Label::new("Screen", row.name.text.clone()) + .aligned() + .left() + .contained() + .with_style(row.name.container) + .flex(1., false), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style(row.container) + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + if let Some(workspace) = this.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.open_shared_screen(peer_id, cx) + }); + } + }) + .into_any() + } + + fn render_header( + section: Section, + theme: &theme::ContactList, + is_selected: bool, + is_collapsed: bool, + cx: &mut ViewContext, + ) -> AnyElement { + enum Header {} + enum LeaveCallContactList {} + + let header_style = theme + .header_row + .in_state(is_selected) + .style_for(&mut Default::default()); + let text = match section { + Section::ActiveCall => "Collaborators", + Section::Requests => "Contact Requests", + Section::Online => "Online", + Section::Offline => "Offline", + }; + let leave_call = if section == Section::ActiveCall { + Some( + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme.leave_call.style_for(state); + Label::new("Leave Call", style.text.clone()) + .contained() + .with_style(style.container) + }) + .on_click(MouseButton::Left, |_, _, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }) + .aligned(), + ) + } else { + None + }; + + let icon_size = theme.section_icon_size; + MouseEventHandler::::new(section as usize, cx, |_, _| { + Flex::row() + .with_child( + Svg::new(if is_collapsed { + "icons/chevron_right_8.svg" + } else { + "icons/chevron_down_8.svg" + }) + .with_color(header_style.text.color) + .constrained() + .with_max_width(icon_size) + .with_max_height(icon_size) + .aligned() + .constrained() + .with_width(icon_size), + ) + .with_child( + Label::new(text, header_style.text.clone()) + .aligned() + .left() + .contained() + .with_margin_left(theme.contact_username.container.margin.left) + .flex(1., true), + ) + .with_children(leave_call) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style(header_style.container) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.toggle_expanded(section, cx); + }) + .into_any() + } + + fn render_contact( + contact: &Contact, + calling: bool, + project: &ModelHandle, + theme: &theme::ContactList, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + let online = contact.online; + let busy = contact.busy || calling; + let user_id = contact.user.id; + let github_login = contact.user.github_login.clone(); + let initial_project = project.clone(); + let mut event_handler = + MouseEventHandler::::new(contact.user.id as usize, cx, |_, cx| { + Flex::row() + .with_children(contact.user.avatar.clone().map(|avatar| { + let status_badge = if contact.online { + Some( + Empty::new() + .collapsed() + .contained() + .with_style(if busy { + theme.contact_status_busy + } else { + theme.contact_status_free + }) + .aligned(), + ) + } else { + None + }; + Stack::new() + .with_child( + Image::from_data(avatar) + .with_style(theme.contact_avatar) + .aligned() + .left(), + ) + .with_children(status_badge) + })) + .with_child( + Label::new( + contact.user.github_login.clone(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true), + ) + .with_child( + MouseEventHandler::::new( + contact.user.id as usize, + cx, + |mouse_state, _| { + let button_style = theme.contact_button.style_for(mouse_state); + render_icon_button(button_style, "icons/x_mark_8.svg") + .aligned() + .flex_float() + }, + ) + .with_padding(Padding::uniform(2.)) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.remove_contact(user_id, &github_login, cx); + }) + .flex_float(), + ) + .with_children(if calling { + Some( + Label::new("Calling", theme.calling_indicator.text.clone()) + .contained() + .with_style(theme.calling_indicator.container) + .aligned(), + ) + } else { + None + }) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .in_state(is_selected) + .style_for(&mut Default::default()), + ) + }) + .on_click(MouseButton::Left, move |_, this, cx| { + if online && !busy { + this.call(user_id, Some(initial_project.clone()), cx); + } + }); + + if online { + event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand); + } + + event_handler.into_any() + } + + fn render_contact_request( + user: Arc, + user_store: ModelHandle, + theme: &theme::ContactList, + is_incoming: bool, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + enum Decline {} + enum Accept {} + enum Cancel {} + + let mut row = 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(), + theme.contact_username.text.clone(), + ) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true), + ); + + let user_id = user.id; + let github_login = user.github_login.clone(); + let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user); + let button_spacing = theme.contact_button_spacing; + + if is_incoming { + row.add_child( + MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { + let button_style = if is_contact_request_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state) + }; + render_icon_button(button_style, "icons/x_mark_8.svg").aligned() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.respond_to_contact_request(user_id, false, cx); + }) + .contained() + .with_margin_right(button_spacing), + ); + + row.add_child( + MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { + let button_style = if is_contact_request_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state) + }; + render_icon_button(button_style, "icons/check_8.svg") + .aligned() + .flex_float() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.respond_to_contact_request(user_id, true, cx); + }), + ); + } else { + row.add_child( + MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { + let button_style = if is_contact_request_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state) + }; + render_icon_button(button_style, "icons/x_mark_8.svg") + .aligned() + .flex_float() + }) + .with_padding(Padding::uniform(2.)) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.remove_contact(user_id, &github_login, cx); + }) + .flex_float(), + ); + } + + row.constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .in_state(is_selected) + .style_for(&mut Default::default()), + ) + .into_any() + } + + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + if self.contact_finder.take().is_some() { + cx.notify(); + return; + } + + let did_clear = self.filter_editor.update(cx, |editor, cx| { + if editor.buffer().read(cx).len(cx) > 0 { + editor.set_text("", cx); + true + } else { + false + } + }); + + if !did_clear { + cx.emit(Event::Dismissed); + } + } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + if let Some(ix) = self.selection { + if self.entries.len() > ix + 1 { + self.selection = Some(ix + 1); + } + } else if !self.entries.is_empty() { + self.selection = Some(0); + } + self.list_state.reset(self.entries.len()); + if let Some(ix) = self.selection { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: 0., + }); + } + cx.notify(); + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + if let Some(ix) = self.selection { + if ix > 0 { + self.selection = Some(ix - 1); + } else { + self.selection = None; + } + } + self.list_state.reset(self.entries.len()); + if let Some(ix) = self.selection { + self.list_state.scroll_to(ListOffset { + item_ix: ix, + offset_in_item: 0., + }); + } + cx.notify(); + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if let Some(selection) = self.selection { + if let Some(entry) = self.entries.get(selection) { + match entry { + ContactEntry::Header(section) => { + self.toggle_expanded(*section, cx); + } + ContactEntry::Contact { contact, calling } => { + if contact.online && !contact.busy && !calling { + self.call(contact.user.id, Some(self.project.clone()), cx); + } + } + ContactEntry::ParticipantProject { + project_id, + host_user_id, + .. + } => { + if let Some(workspace) = self.workspace.upgrade(cx) { + let app_state = workspace.read(cx).app_state().clone(); + workspace::join_remote_project( + *project_id, + *host_user_id, + app_state, + cx, + ) + .detach_and_log_err(cx); + } + } + ContactEntry::ParticipantScreen { peer_id, .. } => { + if let Some(workspace) = self.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.open_shared_screen(*peer_id, cx) + }); + } + } + _ => {} + } + } + } + } + + fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext) { + if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) { + self.collapsed_sections.remove(ix); + } else { + self.collapsed_sections.push(section); + } + self.update_entries(cx); + } + + fn toggle_contact_finder(&mut self, cx: &mut ViewContext) { + if self.contact_finder.take().is_none() { + let child = cx.add_view(|cx| { + let finder = build_contact_finder(self.user_store.clone(), cx); + finder.set_query(self.filter_editor.read(cx).text(cx), cx); + finder + }); + cx.focus(&child); + // self.subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { + // // PickerEvent::Dismiss => cx.emit(Event::Dismissed), + // })); + self.contact_finder = Some(child); + } + cx.notify(); + } + + fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext) { + let user_store = self.user_store.clone(); + let prompt_message = format!( + "Are you sure you want to remove \"{}\" from your contacts?", + github_login + ); + let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]); + let window_id = cx.window_id(); + cx.spawn(|_, mut cx| async move { + if answer.next().await == Some(0) { + if let Err(e) = user_store + .update(&mut cx, |store, cx| store.remove_contact(user_id, cx)) + .await + { + cx.prompt( + window_id, + PromptLevel::Info, + &format!("Failed to remove contact: {}", e), + &["Ok"], + ); + } + } + }) + .detach(); + } + + fn respond_to_contact_request( + &mut self, + user_id: u64, + accept: bool, + cx: &mut ViewContext, + ) { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(user_id, accept, cx) + }) + .detach(); + } + + fn call( + &mut self, + recipient_user_id: u64, + initial_project: Option>, + cx: &mut ViewContext, + ) { + ActiveCall::global(cx) + .update(cx, |call, cx| { + call.invite(recipient_user_id, initial_project, cx) + }) + .detach_and_log_err(cx); + } } impl View for CollabPanel { fn ui_name() -> &'static str { - "ChannelsPanel" + "CollabPanel" } fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { @@ -160,28 +1338,58 @@ impl View for CollabPanel { } fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { + enum AddContact {} let theme = theme::current(cx).clone(); - enum ChannelsPanelScrollTag {} Stack::new() - .with_child( - // Full panel column + .with_child(if let Some(finder) = &self.contact_finder { + ChildView::new(&finder, cx).into_any() + } else { Flex::column() .with_child( - Flex::column() + Flex::row() .with_child( - Flex::row().with_child( - Label::new( - "Contacts", - theme.editor.invalid_information_diagnostic.message.clone(), + ChildView::new(&self.filter_editor, cx) + .contained() + .with_style(theme.contact_list.user_query_editor.container) + .flex(1.0, true), + ) + .with_child( + MouseEventHandler::::new(0, cx, |_, _| { + render_icon_button( + &theme.contact_list.add_contact_button, + "icons/user_plus_16.svg", ) - .into_any(), - ), + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, this, cx| { + this.toggle_contact_finder(cx); + }) + .with_tooltip::( + 0, + "Search for new contact".into(), + None, + theme.tooltip.clone(), + cx, + ) + .constrained() + .with_height(theme.contact_list.user_query_editor_height) + .with_width(theme.contact_list.user_query_editor_height), ) - .with_child(ChildView::new(&self.contacts, cx)), + .constrained() + .with_width(self.size(cx)), ) - .scrollable::(0, None, cx), - ) + .with_child( + List::new(self.list_state.clone()) + .constrained() + .with_width(self.size(cx)) + .flex(1., true) + .into_any(), + ) + .constrained() + .with_width(self.size(cx)) + .into_any() + }) .with_child(ChildView::new(&self.context_menu, cx)) .into_any_named("channels panel") .into_any() @@ -245,3 +1453,76 @@ impl Panel for CollabPanel { matches!(event, Event::Focus) } } + +impl PartialEq for ContactEntry { + fn eq(&self, other: &Self) -> bool { + match self { + ContactEntry::Header(section_1) => { + if let ContactEntry::Header(section_2) = other { + return section_1 == section_2; + } + } + ContactEntry::CallParticipant { user: user_1, .. } => { + if let ContactEntry::CallParticipant { user: user_2, .. } = other { + return user_1.id == user_2.id; + } + } + ContactEntry::ParticipantProject { + project_id: project_id_1, + .. + } => { + if let ContactEntry::ParticipantProject { + project_id: project_id_2, + .. + } = other + { + return project_id_1 == project_id_2; + } + } + ContactEntry::ParticipantScreen { + peer_id: peer_id_1, .. + } => { + if let ContactEntry::ParticipantScreen { + peer_id: peer_id_2, .. + } = other + { + return peer_id_1 == peer_id_2; + } + } + ContactEntry::IncomingRequest(user_1) => { + if let ContactEntry::IncomingRequest(user_2) = other { + return user_1.id == user_2.id; + } + } + ContactEntry::OutgoingRequest(user_1) => { + if let ContactEntry::OutgoingRequest(user_2) = other { + return user_1.id == user_2.id; + } + } + ContactEntry::Contact { + contact: contact_1, .. + } => { + if let ContactEntry::Contact { + contact: contact_2, .. + } = other + { + return contact_1.user.id == contact_2.user.id; + } + } + } + false + } +} + +fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { + Svg::new(svg_path) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) +} diff --git a/crates/collab_ui/src/panel/contacts/contact_finder.rs b/crates/collab_ui/src/panel/contact_finder.rs similarity index 100% rename from crates/collab_ui/src/panel/contacts/contact_finder.rs rename to crates/collab_ui/src/panel/contact_finder.rs diff --git a/crates/collab_ui/src/panel/contacts.rs b/crates/collab_ui/src/panel/contacts.rs deleted file mode 100644 index a1c1061f5e0845c37370580df608121b6db1aa29..0000000000000000000000000000000000000000 --- a/crates/collab_ui/src/panel/contacts.rs +++ /dev/null @@ -1,140 +0,0 @@ -mod contact_finder; -mod contacts_list; - -use client::UserStore; -use gpui::{ - actions, elements::*, platform::MouseButton, AppContext, Entity, ModelHandle, View, - ViewContext, ViewHandle, WeakViewHandle, -}; -use picker::PickerEvent; -use project::Project; -use workspace::Workspace; - -use self::{contacts_list::ContactList, contact_finder::{ContactFinder, build_contact_finder}}; - -actions!(contacts_popover, [ToggleContactFinder]); - -pub fn init(cx: &mut AppContext) { - cx.add_action(Contacts::toggle_contact_finder); - contact_finder::init(cx); - contacts_list::init(cx); -} - -pub enum Event { - Dismissed, -} - -enum Child { - ContactList(ViewHandle), - ContactFinder(ViewHandle), -} - -pub struct Contacts { - child: Child, - project: ModelHandle, - user_store: ModelHandle, - workspace: WeakViewHandle, - _subscription: Option, -} - -impl Contacts { - pub fn new( - project: ModelHandle, - user_store: ModelHandle, - workspace: WeakViewHandle, - cx: &mut ViewContext, - ) -> Self { - let mut this = Self { - child: Child::ContactList(cx.add_view(|cx| { - ContactList::new(project.clone(), user_store.clone(), workspace.clone(), cx) - })), - project, - user_store, - workspace, - _subscription: None, - }; - this.show_contact_list(String::new(), cx); - this - } - - fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext) { - match &self.child { - Child::ContactList(list) => self.show_contact_finder(list.read(cx).editor_text(cx), cx), - Child::ContactFinder(finder) => self.show_contact_list(finder.read(cx).query(cx), cx), - } - } - - fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext) { - let child = cx.add_view(|cx| { - let finder = build_contact_finder(self.user_store.clone(), cx); - finder.set_query(editor_text, cx); - finder - }); - cx.focus(&child); - self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { - PickerEvent::Dismiss => cx.emit(Event::Dismissed), - })); - self.child = Child::ContactFinder(child); - cx.notify(); - } - - fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext) { - let child = cx.add_view(|cx| { - ContactList::new( - self.project.clone(), - self.user_store.clone(), - self.workspace.clone(), - cx, - ) - .with_editor_text(editor_text, cx) - }); - cx.focus(&child); - self._subscription = Some(cx.subscribe(&child, |this, _, event, cx| match event { - contacts_list::Event::Dismissed => cx.emit(Event::Dismissed), - contacts_list::Event::ToggleContactFinder => { - this.toggle_contact_finder(&Default::default(), cx) - } - })); - self.child = Child::ContactList(child); - cx.notify(); - } -} - -impl Entity for Contacts { - type Event = Event; -} - -impl View for Contacts { - fn ui_name() -> &'static str { - "ContactsPopover" - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = theme::current(cx).clone(); - let child = match &self.child { - Child::ContactList(child) => ChildView::new(child, cx), - Child::ContactFinder(child) => ChildView::new(child, cx), - }; - - MouseEventHandler::::new(0, cx, |_, _| { - Flex::column() - .with_child(child) - .contained() - .with_style(theme.contacts_popover.container) - .constrained() - .with_width(theme.contacts_popover.width) - .with_height(theme.contacts_popover.height) - }) - .on_down_out(MouseButton::Left, move |_, _, cx| cx.emit(Event::Dismissed)) - .into_any() - } - - fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { - if cx.is_self_focused() { - match &self.child { - Child::ContactList(child) => cx.focus(child), - Child::ContactFinder(child) => cx.focus(child), - } - } - } -} diff --git a/crates/collab_ui/src/panel/contacts/contacts_list.rs b/crates/collab_ui/src/panel/contacts/contacts_list.rs deleted file mode 100644 index f37d64cd05170a4e9992d8789f7642c0ec8a7715..0000000000000000000000000000000000000000 --- a/crates/collab_ui/src/panel/contacts/contacts_list.rs +++ /dev/null @@ -1,1384 +0,0 @@ -use call::ActiveCall; -use client::{proto::PeerId, Contact, User, UserStore}; -use editor::{Cancel, Editor}; -use futures::StreamExt; -use fuzzy::{match_strings, StringMatchCandidate}; -use gpui::{ - elements::*, - geometry::{rect::RectF, vector::vec2f}, - impl_actions, - keymap_matcher::KeymapContext, - platform::{CursorStyle, MouseButton, PromptLevel}, - AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, -}; -use menu::{Confirm, SelectNext, SelectPrev}; -use project::Project; -use serde::Deserialize; -use std::{mem, sync::Arc}; -use theme::IconButton; -use workspace::Workspace; - -impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]); - -pub fn init(cx: &mut AppContext) { - cx.add_action(ContactList::remove_contact); - cx.add_action(ContactList::respond_to_contact_request); - cx.add_action(ContactList::cancel); - cx.add_action(ContactList::select_next); - cx.add_action(ContactList::select_prev); - cx.add_action(ContactList::confirm); -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] -enum Section { - ActiveCall, - Requests, - Online, - Offline, -} - -#[derive(Clone)] -enum ContactEntry { - Header(Section), - CallParticipant { - user: Arc, - is_pending: bool, - }, - ParticipantProject { - project_id: u64, - worktree_root_names: Vec, - host_user_id: u64, - is_last: bool, - }, - ParticipantScreen { - peer_id: PeerId, - is_last: bool, - }, - IncomingRequest(Arc), - OutgoingRequest(Arc), - Contact { - contact: Arc, - calling: bool, - }, -} - -impl PartialEq for ContactEntry { - fn eq(&self, other: &Self) -> bool { - match self { - ContactEntry::Header(section_1) => { - if let ContactEntry::Header(section_2) = other { - return section_1 == section_2; - } - } - ContactEntry::CallParticipant { user: user_1, .. } => { - if let ContactEntry::CallParticipant { user: user_2, .. } = other { - return user_1.id == user_2.id; - } - } - ContactEntry::ParticipantProject { - project_id: project_id_1, - .. - } => { - if let ContactEntry::ParticipantProject { - project_id: project_id_2, - .. - } = other - { - return project_id_1 == project_id_2; - } - } - ContactEntry::ParticipantScreen { - peer_id: peer_id_1, .. - } => { - if let ContactEntry::ParticipantScreen { - peer_id: peer_id_2, .. - } = other - { - return peer_id_1 == peer_id_2; - } - } - ContactEntry::IncomingRequest(user_1) => { - if let ContactEntry::IncomingRequest(user_2) = other { - return user_1.id == user_2.id; - } - } - ContactEntry::OutgoingRequest(user_1) => { - if let ContactEntry::OutgoingRequest(user_2) = other { - return user_1.id == user_2.id; - } - } - ContactEntry::Contact { - contact: contact_1, .. - } => { - if let ContactEntry::Contact { - contact: contact_2, .. - } = other - { - return contact_1.user.id == contact_2.user.id; - } - } - } - false - } -} - -#[derive(Clone, Deserialize, PartialEq)] -pub struct RequestContact(pub u64); - -#[derive(Clone, Deserialize, PartialEq)] -pub struct RemoveContact { - user_id: u64, - github_login: String, -} - -#[derive(Clone, Deserialize, PartialEq)] -pub struct RespondToContactRequest { - pub user_id: u64, - pub accept: bool, -} - -pub enum Event { - ToggleContactFinder, - Dismissed, -} - -pub struct ContactList { - entries: Vec, - match_candidates: Vec, - list_state: ListState, - project: ModelHandle, - workspace: WeakViewHandle, - user_store: ModelHandle, - filter_editor: ViewHandle, - collapsed_sections: Vec
, - selection: Option, - _subscriptions: Vec, -} - -impl ContactList { - pub fn new( - project: ModelHandle, - user_store: ModelHandle, - workspace: WeakViewHandle, - cx: &mut ViewContext, - ) -> Self { - let filter_editor = cx.add_view(|cx| { - let mut editor = Editor::single_line( - Some(Arc::new(|theme| { - theme.contact_list.user_query_editor.clone() - })), - cx, - ); - editor.set_placeholder_text("Filter contacts", cx); - editor - }); - - cx.subscribe(&filter_editor, |this, _, event, cx| { - if let editor::Event::BufferEdited = event { - let query = this.filter_editor.read(cx).text(cx); - if !query.is_empty() { - this.selection.take(); - } - this.update_entries(cx); - if !query.is_empty() { - this.selection = this - .entries - .iter() - .position(|entry| !matches!(entry, ContactEntry::Header(_))); - } - } - }) - .detach(); - - let list_state = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { - let theme = theme::current(cx).clone(); - let is_selected = this.selection == Some(ix); - let current_project_id = this.project.read(cx).remote_id(); - - match &this.entries[ix] { - ContactEntry::Header(section) => { - let is_collapsed = this.collapsed_sections.contains(section); - Self::render_header( - *section, - &theme.contact_list, - is_selected, - is_collapsed, - cx, - ) - } - ContactEntry::CallParticipant { user, is_pending } => { - Self::render_call_participant( - user, - *is_pending, - is_selected, - &theme.contact_list, - ) - } - ContactEntry::ParticipantProject { - project_id, - worktree_root_names, - host_user_id, - is_last, - } => Self::render_participant_project( - *project_id, - worktree_root_names, - *host_user_id, - Some(*project_id) == current_project_id, - *is_last, - is_selected, - &theme.contact_list, - cx, - ), - ContactEntry::ParticipantScreen { peer_id, is_last } => { - Self::render_participant_screen( - *peer_id, - *is_last, - is_selected, - &theme.contact_list, - cx, - ) - } - ContactEntry::IncomingRequest(user) => Self::render_contact_request( - user.clone(), - this.user_store.clone(), - &theme.contact_list, - true, - is_selected, - cx, - ), - ContactEntry::OutgoingRequest(user) => Self::render_contact_request( - user.clone(), - this.user_store.clone(), - &theme.contact_list, - false, - is_selected, - cx, - ), - ContactEntry::Contact { contact, calling } => Self::render_contact( - contact, - *calling, - &this.project, - &theme.contact_list, - is_selected, - cx, - ), - } - }); - - let active_call = ActiveCall::global(cx); - let mut subscriptions = Vec::new(); - subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx))); - subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx))); - - let mut this = Self { - list_state, - selection: None, - collapsed_sections: Default::default(), - entries: Default::default(), - match_candidates: Default::default(), - filter_editor, - _subscriptions: subscriptions, - project, - workspace, - user_store, - }; - this.update_entries(cx); - this - } - - pub fn editor_text(&self, cx: &AppContext) -> String { - self.filter_editor.read(cx).text(cx) - } - - pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext) -> Self { - self.filter_editor - .update(cx, |picker, cx| picker.set_text(editor_text, cx)); - self - } - - fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext) { - let user_id = request.user_id; - let github_login = &request.github_login; - let user_store = self.user_store.clone(); - let prompt_message = format!( - "Are you sure you want to remove \"{}\" from your contacts?", - github_login - ); - let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]); - let window_id = cx.window_id(); - cx.spawn(|_, mut cx| async move { - if answer.next().await == Some(0) { - if let Err(e) = user_store - .update(&mut cx, |store, cx| store.remove_contact(user_id, cx)) - .await - { - cx.prompt( - window_id, - PromptLevel::Info, - &format!("Failed to remove contact: {}", e), - &["Ok"], - ); - } - } - }) - .detach(); - } - - fn respond_to_contact_request( - &mut self, - action: &RespondToContactRequest, - cx: &mut ViewContext, - ) { - self.user_store - .update(cx, |store, cx| { - store.respond_to_contact_request(action.user_id, action.accept, cx) - }) - .detach(); - } - - fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - let did_clear = self.filter_editor.update(cx, |editor, cx| { - if editor.buffer().read(cx).len(cx) > 0 { - editor.set_text("", cx); - true - } else { - false - } - }); - - if !did_clear { - cx.emit(Event::Dismissed); - } - } - - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - if let Some(ix) = self.selection { - if self.entries.len() > ix + 1 { - self.selection = Some(ix + 1); - } - } else if !self.entries.is_empty() { - self.selection = Some(0); - } - self.list_state.reset(self.entries.len()); - if let Some(ix) = self.selection { - self.list_state.scroll_to(ListOffset { - item_ix: ix, - offset_in_item: 0., - }); - } - cx.notify(); - } - - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if let Some(ix) = self.selection { - if ix > 0 { - self.selection = Some(ix - 1); - } else { - self.selection = None; - } - } - self.list_state.reset(self.entries.len()); - if let Some(ix) = self.selection { - self.list_state.scroll_to(ListOffset { - item_ix: ix, - offset_in_item: 0., - }); - } - cx.notify(); - } - - fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { - if let Some(selection) = self.selection { - if let Some(entry) = self.entries.get(selection) { - match entry { - ContactEntry::Header(section) => { - self.toggle_expanded(*section, cx); - } - ContactEntry::Contact { contact, calling } => { - if contact.online && !contact.busy && !calling { - self.call(contact.user.id, Some(self.project.clone()), cx); - } - } - ContactEntry::ParticipantProject { - project_id, - host_user_id, - .. - } => { - if let Some(workspace) = self.workspace.upgrade(cx) { - let app_state = workspace.read(cx).app_state().clone(); - workspace::join_remote_project( - *project_id, - *host_user_id, - app_state, - cx, - ) - .detach_and_log_err(cx); - } - } - ContactEntry::ParticipantScreen { peer_id, .. } => { - if let Some(workspace) = self.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - workspace.open_shared_screen(*peer_id, cx) - }); - } - } - _ => {} - } - } - } - } - - fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext) { - if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) { - self.collapsed_sections.remove(ix); - } else { - self.collapsed_sections.push(section); - } - self.update_entries(cx); - } - - fn update_entries(&mut self, cx: &mut ViewContext) { - let user_store = self.user_store.read(cx); - let query = self.filter_editor.read(cx).text(cx); - let executor = cx.background().clone(); - - let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); - let old_entries = mem::take(&mut self.entries); - - if let Some(room) = ActiveCall::global(cx).read(cx).room() { - let room = room.read(cx); - let mut participant_entries = Vec::new(); - - // Populate the active user. - if let Some(user) = user_store.current_user() { - self.match_candidates.clear(); - self.match_candidates.push(StringMatchCandidate { - id: 0, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - if !matches.is_empty() { - let user_id = user.id; - participant_entries.push(ContactEntry::CallParticipant { - user, - is_pending: false, - }); - let mut projects = room.local_participant().projects.iter().peekable(); - while let Some(project) = projects.next() { - participant_entries.push(ContactEntry::ParticipantProject { - project_id: project.id, - worktree_root_names: project.worktree_root_names.clone(), - host_user_id: user_id, - is_last: projects.peek().is_none(), - }); - } - } - } - - // Populate remote participants. - self.match_candidates.clear(); - self.match_candidates - .extend(room.remote_participants().iter().map(|(_, participant)| { - StringMatchCandidate { - id: participant.user.id as usize, - string: participant.user.github_login.clone(), - char_bag: participant.user.github_login.chars().collect(), - } - })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - for mat in matches { - let user_id = mat.candidate_id as u64; - let participant = &room.remote_participants()[&user_id]; - participant_entries.push(ContactEntry::CallParticipant { - user: participant.user.clone(), - is_pending: false, - }); - let mut projects = participant.projects.iter().peekable(); - while let Some(project) = projects.next() { - participant_entries.push(ContactEntry::ParticipantProject { - project_id: project.id, - worktree_root_names: project.worktree_root_names.clone(), - host_user_id: participant.user.id, - is_last: projects.peek().is_none() && participant.video_tracks.is_empty(), - }); - } - if !participant.video_tracks.is_empty() { - participant_entries.push(ContactEntry::ParticipantScreen { - peer_id: participant.peer_id, - is_last: true, - }); - } - } - - // Populate pending participants. - self.match_candidates.clear(); - self.match_candidates - .extend( - room.pending_participants() - .iter() - .enumerate() - .map(|(id, participant)| StringMatchCandidate { - id, - string: participant.github_login.clone(), - char_bag: participant.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant { - user: room.pending_participants()[mat.candidate_id].clone(), - is_pending: true, - })); - - if !participant_entries.is_empty() { - self.entries.push(ContactEntry::Header(Section::ActiveCall)); - if !self.collapsed_sections.contains(&Section::ActiveCall) { - self.entries.extend(participant_entries); - } - } - } - - let mut request_entries = Vec::new(); - let incoming = user_store.incoming_contact_requests(); - if !incoming.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - incoming - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), - ); - } - - let outgoing = user_store.outgoing_contact_requests(); - if !outgoing.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - outgoing - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), - ); - } - - if !request_entries.is_empty() { - self.entries.push(ContactEntry::Header(Section::Requests)); - if !self.collapsed_sections.contains(&Section::Requests) { - self.entries.append(&mut request_entries); - } - } - - let contacts = user_store.contacts(); - if !contacts.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - contacts - .iter() - .enumerate() - .map(|(ix, contact)| StringMatchCandidate { - id: ix, - string: contact.user.github_login.clone(), - char_bag: contact.user.github_login.chars().collect(), - }), - ); - - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - - let (mut online_contacts, offline_contacts) = matches - .iter() - .partition::, _>(|mat| contacts[mat.candidate_id].online); - if let Some(room) = ActiveCall::global(cx).read(cx).room() { - let room = room.read(cx); - online_contacts.retain(|contact| { - let contact = &contacts[contact.candidate_id]; - !room.contains_participant(contact.user.id) - }); - } - - for (matches, section) in [ - (online_contacts, Section::Online), - (offline_contacts, Section::Offline), - ] { - if !matches.is_empty() { - self.entries.push(ContactEntry::Header(section)); - if !self.collapsed_sections.contains(§ion) { - let active_call = &ActiveCall::global(cx).read(cx); - for mat in matches { - let contact = &contacts[mat.candidate_id]; - self.entries.push(ContactEntry::Contact { - contact: contact.clone(), - calling: active_call.pending_invites().contains(&contact.user.id), - }); - } - } - } - } - } - - if let Some(prev_selected_entry) = prev_selected_entry { - self.selection.take(); - for (ix, entry) in self.entries.iter().enumerate() { - if *entry == prev_selected_entry { - self.selection = Some(ix); - break; - } - } - } - - let old_scroll_top = self.list_state.logical_scroll_top(); - self.list_state.reset(self.entries.len()); - - // Attempt to maintain the same scroll position. - if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) { - let new_scroll_top = self - .entries - .iter() - .position(|entry| entry == old_top_entry) - .map(|item_ix| ListOffset { - item_ix, - offset_in_item: old_scroll_top.offset_in_item, - }) - .or_else(|| { - let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?; - let item_ix = self - .entries - .iter() - .position(|entry| entry == entry_after_old_top)?; - Some(ListOffset { - item_ix, - offset_in_item: 0., - }) - }) - .or_else(|| { - let entry_before_old_top = - old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?; - let item_ix = self - .entries - .iter() - .position(|entry| entry == entry_before_old_top)?; - Some(ListOffset { - item_ix, - offset_in_item: 0., - }) - }); - - self.list_state - .scroll_to(new_scroll_top.unwrap_or(old_scroll_top)); - } - - cx.notify(); - } - - fn render_call_participant( - user: &User, - is_pending: bool, - is_selected: bool, - theme: &theme::ContactList, - ) -> AnyElement { - 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(), - theme.contact_username.text.clone(), - ) - .contained() - .with_style(theme.contact_username.container) - .aligned() - .left() - .flex(1., true), - ) - .with_children(if is_pending { - Some( - Label::new("Calling", theme.calling_indicator.text.clone()) - .contained() - .with_style(theme.calling_indicator.container) - .aligned(), - ) - } else { - None - }) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style( - *theme - .contact_row - .in_state(is_selected) - .style_for(&mut Default::default()), - ) - .into_any() - } - - fn render_participant_project( - project_id: u64, - worktree_root_names: &[String], - host_user_id: u64, - is_current: bool, - is_last: bool, - is_selected: bool, - theme: &theme::ContactList, - cx: &mut ViewContext, - ) -> AnyElement { - enum JoinProject {} - - let font_cache = cx.font_cache(); - let host_avatar_height = theme - .contact_avatar - .width - .or(theme.contact_avatar.height) - .unwrap_or(0.); - let row = &theme.project_row.inactive_state().default; - let tree_branch = theme.tree_branch; - let line_height = row.name.text.line_height(font_cache); - let cap_height = row.name.text.cap_height(font_cache); - let baseline_offset = - row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.; - let project_name = if worktree_root_names.is_empty() { - "untitled".to_string() - } else { - worktree_root_names.join(", ") - }; - - MouseEventHandler::::new(project_id as usize, cx, |mouse_state, _| { - let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); - let row = theme - .project_row - .in_state(is_selected) - .style_for(mouse_state); - - Flex::row() - .with_child( - Stack::new() - .with_child(Canvas::new(move |scene, bounds, _, _, _| { - let start_x = - bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.); - let end_x = bounds.max_x(); - let start_y = bounds.min_y(); - let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); - - scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, start_y), - vec2f( - start_x + tree_branch.width, - if is_last { end_y } else { bounds.max_y() }, - ), - ), - background: Some(tree_branch.color), - border: gpui::Border::default(), - corner_radius: 0., - }); - scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, end_y), - vec2f(end_x, end_y + tree_branch.width), - ), - background: Some(tree_branch.color), - border: gpui::Border::default(), - corner_radius: 0., - }); - })) - .constrained() - .with_width(host_avatar_height), - ) - .with_child( - Label::new(project_name, row.name.text.clone()) - .aligned() - .left() - .contained() - .with_style(row.name.container) - .flex(1., false), - ) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(row.container) - }) - .with_cursor_style(if !is_current { - CursorStyle::PointingHand - } else { - CursorStyle::Arrow - }) - .on_click(MouseButton::Left, move |_, this, cx| { - if !is_current { - if let Some(workspace) = this.workspace.upgrade(cx) { - let app_state = workspace.read(cx).app_state().clone(); - workspace::join_remote_project(project_id, host_user_id, app_state, cx) - .detach_and_log_err(cx); - } - } - }) - .into_any() - } - - fn render_participant_screen( - peer_id: PeerId, - is_last: bool, - is_selected: bool, - theme: &theme::ContactList, - cx: &mut ViewContext, - ) -> AnyElement { - enum OpenSharedScreen {} - - let font_cache = cx.font_cache(); - let host_avatar_height = theme - .contact_avatar - .width - .or(theme.contact_avatar.height) - .unwrap_or(0.); - let row = &theme.project_row.inactive_state().default; - let tree_branch = theme.tree_branch; - let line_height = row.name.text.line_height(font_cache); - let cap_height = row.name.text.cap_height(font_cache); - let baseline_offset = - row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.; - - MouseEventHandler::::new( - peer_id.as_u64() as usize, - cx, - |mouse_state, _| { - let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); - let row = theme - .project_row - .in_state(is_selected) - .style_for(mouse_state); - - Flex::row() - .with_child( - Stack::new() - .with_child(Canvas::new(move |scene, bounds, _, _, _| { - let start_x = bounds.min_x() + (bounds.width() / 2.) - - (tree_branch.width / 2.); - let end_x = bounds.max_x(); - let start_y = bounds.min_y(); - let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); - - scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, start_y), - vec2f( - start_x + tree_branch.width, - if is_last { end_y } else { bounds.max_y() }, - ), - ), - background: Some(tree_branch.color), - border: gpui::Border::default(), - corner_radius: 0., - }); - scene.push_quad(gpui::Quad { - bounds: RectF::from_points( - vec2f(start_x, end_y), - vec2f(end_x, end_y + tree_branch.width), - ), - background: Some(tree_branch.color), - border: gpui::Border::default(), - corner_radius: 0., - }); - })) - .constrained() - .with_width(host_avatar_height), - ) - .with_child( - Svg::new("icons/disable_screen_sharing_12.svg") - .with_color(row.icon.color) - .constrained() - .with_width(row.icon.width) - .aligned() - .left() - .contained() - .with_style(row.icon.container), - ) - .with_child( - Label::new("Screen", row.name.text.clone()) - .aligned() - .left() - .contained() - .with_style(row.name.container) - .flex(1., false), - ) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(row.container) - }, - ) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - if let Some(workspace) = this.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - workspace.open_shared_screen(peer_id, cx) - }); - } - }) - .into_any() - } - - fn render_header( - section: Section, - theme: &theme::ContactList, - is_selected: bool, - is_collapsed: bool, - cx: &mut ViewContext, - ) -> AnyElement { - enum Header {} - enum LeaveCallContactList {} - - let header_style = theme - .header_row - .in_state(is_selected) - .style_for(&mut Default::default()); - let text = match section { - Section::ActiveCall => "Collaborators", - Section::Requests => "Contact Requests", - Section::Online => "Online", - Section::Offline => "Offline", - }; - let leave_call = if section == Section::ActiveCall { - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.leave_call.style_for(state); - Label::new("Leave Call", style.text.clone()) - .contained() - .with_style(style.container) - }) - .on_click(MouseButton::Left, |_, _, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }) - .aligned(), - ) - } else { - None - }; - - let icon_size = theme.section_icon_size; - MouseEventHandler::::new(section as usize, cx, |_, _| { - Flex::row() - .with_child( - Svg::new(if is_collapsed { - "icons/chevron_right_8.svg" - } else { - "icons/chevron_down_8.svg" - }) - .with_color(header_style.text.color) - .constrained() - .with_max_width(icon_size) - .with_max_height(icon_size) - .aligned() - .constrained() - .with_width(icon_size), - ) - .with_child( - Label::new(text, header_style.text.clone()) - .aligned() - .left() - .contained() - .with_margin_left(theme.contact_username.container.margin.left) - .flex(1., true), - ) - .with_children(leave_call) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style(header_style.container) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.toggle_expanded(section, cx); - }) - .into_any() - } - - fn render_contact( - contact: &Contact, - calling: bool, - project: &ModelHandle, - theme: &theme::ContactList, - is_selected: bool, - cx: &mut ViewContext, - ) -> AnyElement { - let online = contact.online; - let busy = contact.busy || calling; - let user_id = contact.user.id; - let github_login = contact.user.github_login.clone(); - let initial_project = project.clone(); - let mut event_handler = - MouseEventHandler::::new(contact.user.id as usize, cx, |_, cx| { - Flex::row() - .with_children(contact.user.avatar.clone().map(|avatar| { - let status_badge = if contact.online { - Some( - Empty::new() - .collapsed() - .contained() - .with_style(if busy { - theme.contact_status_busy - } else { - theme.contact_status_free - }) - .aligned(), - ) - } else { - None - }; - Stack::new() - .with_child( - Image::from_data(avatar) - .with_style(theme.contact_avatar) - .aligned() - .left(), - ) - .with_children(status_badge) - })) - .with_child( - Label::new( - contact.user.github_login.clone(), - theme.contact_username.text.clone(), - ) - .contained() - .with_style(theme.contact_username.container) - .aligned() - .left() - .flex(1., true), - ) - .with_child( - MouseEventHandler::::new( - contact.user.id as usize, - cx, - |mouse_state, _| { - let button_style = theme.contact_button.style_for(mouse_state); - render_icon_button(button_style, "icons/x_mark_8.svg") - .aligned() - .flex_float() - }, - ) - .with_padding(Padding::uniform(2.)) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.remove_contact( - &RemoveContact { - user_id, - github_login: github_login.clone(), - }, - cx, - ); - }) - .flex_float(), - ) - .with_children(if calling { - Some( - Label::new("Calling", theme.calling_indicator.text.clone()) - .contained() - .with_style(theme.calling_indicator.container) - .aligned(), - ) - } else { - None - }) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style( - *theme - .contact_row - .in_state(is_selected) - .style_for(&mut Default::default()), - ) - }) - .on_click(MouseButton::Left, move |_, this, cx| { - if online && !busy { - this.call(user_id, Some(initial_project.clone()), cx); - } - }); - - if online { - event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand); - } - - event_handler.into_any() - } - - fn render_contact_request( - user: Arc, - user_store: ModelHandle, - theme: &theme::ContactList, - is_incoming: bool, - is_selected: bool, - cx: &mut ViewContext, - ) -> AnyElement { - enum Decline {} - enum Accept {} - enum Cancel {} - - let mut row = 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(), - theme.contact_username.text.clone(), - ) - .contained() - .with_style(theme.contact_username.container) - .aligned() - .left() - .flex(1., true), - ); - - let user_id = user.id; - let github_login = user.github_login.clone(); - let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user); - let button_spacing = theme.contact_button_spacing; - - if is_incoming { - row.add_child( - MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { - let button_style = if is_contact_request_pending { - &theme.disabled_button - } else { - theme.contact_button.style_for(mouse_state) - }; - render_icon_button(button_style, "icons/x_mark_8.svg").aligned() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.respond_to_contact_request( - &RespondToContactRequest { - user_id, - accept: false, - }, - cx, - ); - }) - .contained() - .with_margin_right(button_spacing), - ); - - row.add_child( - MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { - let button_style = if is_contact_request_pending { - &theme.disabled_button - } else { - theme.contact_button.style_for(mouse_state) - }; - render_icon_button(button_style, "icons/check_8.svg") - .aligned() - .flex_float() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.respond_to_contact_request( - &RespondToContactRequest { - user_id, - accept: true, - }, - cx, - ); - }), - ); - } else { - row.add_child( - MouseEventHandler::::new(user.id as usize, cx, |mouse_state, _| { - let button_style = if is_contact_request_pending { - &theme.disabled_button - } else { - theme.contact_button.style_for(mouse_state) - }; - render_icon_button(button_style, "icons/x_mark_8.svg") - .aligned() - .flex_float() - }) - .with_padding(Padding::uniform(2.)) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.remove_contact( - &RemoveContact { - user_id, - github_login: github_login.clone(), - }, - cx, - ); - }) - .flex_float(), - ); - } - - row.constrained() - .with_height(theme.row_height) - .contained() - .with_style( - *theme - .contact_row - .in_state(is_selected) - .style_for(&mut Default::default()), - ) - .into_any() - } - - fn call( - &mut self, - recipient_user_id: u64, - initial_project: Option>, - cx: &mut ViewContext, - ) { - ActiveCall::global(cx) - .update(cx, |call, cx| { - call.invite(recipient_user_id, initial_project, cx) - }) - .detach_and_log_err(cx); - } -} - -impl Entity for ContactList { - type Event = Event; -} - -impl View for ContactList { - fn ui_name() -> &'static str { - "ContactList" - } - - fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) { - Self::reset_to_default_keymap_context(keymap); - keymap.add_identifier("menu"); - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - enum AddContact {} - let theme = theme::current(cx).clone(); - - Flex::column() - .with_child( - Flex::row() - // .with_child( - // ChildView::new(&self.filter_editor, cx) - // .contained() - // .with_style(theme.contact_list.user_query_editor.container) - // ) - .with_child( - MouseEventHandler::::new(0, cx, |_, _| { - render_icon_button( - &theme.contact_list.add_contact_button, - "icons/user_plus_16.svg", - ) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, |_, _, cx| { - cx.emit(Event::ToggleContactFinder) - }) - .with_tooltip::( - 0, - "Search for new contact".into(), - None, - theme.tooltip.clone(), - cx, - ), - ) - .constrained() - .with_height(theme.contact_list.user_query_editor_height), - ) - // .with_child(List::new(self.list_state.clone())) - .into_any() - } - - fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { - if !self.filter_editor.is_focused(cx) { - cx.focus(&self.filter_editor); - } - } - - fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { - if !self.filter_editor.is_focused(cx) { - cx.emit(Event::Dismissed); - } - } -} - -fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { - Svg::new(svg_path) - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .contained() - .with_style(style.container) - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) -} diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index 746238aaa99b248b0cc8606b9ee87ebd40871f84..533a5de159543a38b8453eade414edf7f16b17eb 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -271,8 +271,16 @@ impl> AnyElementState for ElementState { | ElementState::PostLayout { mut element, .. } | ElementState::PostPaint { mut element, .. } => { let (size, layout) = element.layout(constraint, view, cx); - debug_assert!(size.x().is_finite(), "Element for {:?} had infinite x size after layout", element.view_name()); - debug_assert!(size.y().is_finite(), "Element for {:?} had infinite x size after layout", element.view_name()); + debug_assert!( + size.x().is_finite(), + "Element for {:?} had infinite x size after layout", + element.view_name() + ); + debug_assert!( + size.y().is_finite(), + "Element for {:?} had infinite y size after layout", + element.view_name() + ); result = size; ElementState::PostLayout { diff --git a/styles/src/style_tree/contacts_popover.ts b/styles/src/style_tree/contacts_popover.ts index 0ce63d088a72028e6e221cc5739adfe2376d608d..7ca258d1662d921cbba9d347b7022b8be65c2dd0 100644 --- a/styles/src/style_tree/contacts_popover.ts +++ b/styles/src/style_tree/contacts_popover.ts @@ -5,10 +5,10 @@ export default function contacts_popover(): any { const theme = useTheme() return { - background: background(theme.middle), - corner_radius: 6, + // background: background(theme.middle), + // corner_radius: 6, padding: { top: 6, bottom: 6 }, - shadow: theme.popover_shadow, + // shadow: theme.popover_shadow, border: border(theme.middle), width: 300, height: 400, From 87dfce94ae5be393a5dca1f4584321d85b89f971 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 25 Jul 2023 10:02:01 -0700 Subject: [PATCH 005/105] Rename contact list theme to collab panel --- crates/collab_ui/src/panel.rs | 36 +++++++++---------- crates/collab_ui/src/panel/contact_finder.rs | 19 ++++------ crates/theme/src/theme.rs | 6 ++-- styles/src/style_tree/app.ts | 6 ++-- .../{contact_list.ts => collab_panel.ts} | 9 ++--- 5 files changed, 36 insertions(+), 40 deletions(-) rename styles/src/style_tree/{contact_list.ts => collab_panel.ts} (95%) diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 28cb57cf7942ab2e095f85e11d37ce9d9e09f5c0..4fee5f66f10b546094dd57198a4afe6e0337c385 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -126,7 +126,7 @@ impl CollabPanel { let filter_editor = cx.add_view(|cx| { let mut editor = Editor::single_line( Some(Arc::new(|theme| { - theme.contact_list.user_query_editor.clone() + theme.collab_panel.user_query_editor.clone() })), cx, ); @@ -162,7 +162,7 @@ impl CollabPanel { let is_collapsed = this.collapsed_sections.contains(section); Self::render_header( *section, - &theme.contact_list, + &theme.collab_panel, is_selected, is_collapsed, cx, @@ -173,7 +173,7 @@ impl CollabPanel { user, *is_pending, is_selected, - &theme.contact_list, + &theme.collab_panel, ) } ContactEntry::ParticipantProject { @@ -188,7 +188,7 @@ impl CollabPanel { Some(*project_id) == current_project_id, *is_last, is_selected, - &theme.contact_list, + &theme.collab_panel, cx, ), ContactEntry::ParticipantScreen { peer_id, is_last } => { @@ -196,14 +196,14 @@ impl CollabPanel { *peer_id, *is_last, is_selected, - &theme.contact_list, + &theme.collab_panel, cx, ) } ContactEntry::IncomingRequest(user) => Self::render_contact_request( user.clone(), this.user_store.clone(), - &theme.contact_list, + &theme.collab_panel, true, is_selected, cx, @@ -211,7 +211,7 @@ impl CollabPanel { ContactEntry::OutgoingRequest(user) => Self::render_contact_request( user.clone(), this.user_store.clone(), - &theme.contact_list, + &theme.collab_panel, false, is_selected, cx, @@ -220,7 +220,7 @@ impl CollabPanel { contact, *calling, &this.project, - &theme.contact_list, + &theme.collab_panel, is_selected, cx, ), @@ -617,7 +617,7 @@ impl CollabPanel { user: &User, is_pending: bool, is_selected: bool, - theme: &theme::ContactList, + theme: &theme::CollabPanel, ) -> AnyElement { Flex::row() .with_children(user.avatar.clone().map(|avatar| { @@ -666,7 +666,7 @@ impl CollabPanel { is_current: bool, is_last: bool, is_selected: bool, - theme: &theme::ContactList, + theme: &theme::CollabPanel, cx: &mut ViewContext, ) -> AnyElement { enum JoinProject {} @@ -765,7 +765,7 @@ impl CollabPanel { peer_id: PeerId, is_last: bool, is_selected: bool, - theme: &theme::ContactList, + theme: &theme::CollabPanel, cx: &mut ViewContext, ) -> AnyElement { enum OpenSharedScreen {} @@ -865,7 +865,7 @@ impl CollabPanel { fn render_header( section: Section, - theme: &theme::ContactList, + theme: &theme::CollabPanel, is_selected: bool, is_collapsed: bool, cx: &mut ViewContext, @@ -944,7 +944,7 @@ impl CollabPanel { contact: &Contact, calling: bool, project: &ModelHandle, - theme: &theme::ContactList, + theme: &theme::CollabPanel, is_selected: bool, cx: &mut ViewContext, ) -> AnyElement { @@ -1046,7 +1046,7 @@ impl CollabPanel { fn render_contact_request( user: Arc, user_store: ModelHandle, - theme: &theme::ContactList, + theme: &theme::CollabPanel, is_incoming: bool, is_selected: bool, cx: &mut ViewContext, @@ -1351,13 +1351,13 @@ impl View for CollabPanel { .with_child( ChildView::new(&self.filter_editor, cx) .contained() - .with_style(theme.contact_list.user_query_editor.container) + .with_style(theme.collab_panel.user_query_editor.container) .flex(1.0, true), ) .with_child( MouseEventHandler::::new(0, cx, |_, _| { render_icon_button( - &theme.contact_list.add_contact_button, + &theme.collab_panel.add_contact_button, "icons/user_plus_16.svg", ) }) @@ -1373,8 +1373,8 @@ impl View for CollabPanel { cx, ) .constrained() - .with_height(theme.contact_list.user_query_editor_height) - .with_width(theme.contact_list.user_query_editor_height), + .with_height(theme.collab_panel.user_query_editor_height) + .with_width(theme.collab_panel.user_query_editor_height), ) .constrained() .with_width(self.size(cx)), diff --git a/crates/collab_ui/src/panel/contact_finder.rs b/crates/collab_ui/src/panel/contact_finder.rs index 3264a144ed4bc794b2bf0a052fd862c8534d18fd..a5868f8d2fbc4e01a6650b49e16d6f167453140b 100644 --- a/crates/collab_ui/src/panel/contact_finder.rs +++ b/crates/collab_ui/src/panel/contact_finder.rs @@ -97,7 +97,7 @@ impl PickerDelegate for ContactFinderDelegate { selected: bool, cx: &gpui::AppContext, ) -> AnyElement> { - let theme = &theme::current(cx); + let theme = &theme::current(cx).contact_finder; let user = &self.potential_contacts[ix]; let request_status = self.user_store.read(cx).contact_request_status(user); @@ -109,27 +109,22 @@ impl PickerDelegate for ContactFinderDelegate { ContactRequestStatus::RequestAccepted => None, }; let button_style = if self.user_store.read(cx).is_contact_request_pending(user) { - &theme.contact_finder.disabled_contact_button + &theme.disabled_contact_button } else { - &theme.contact_finder.contact_button + &theme.contact_button }; - let style = theme - .contact_finder - .picker - .item - .in_state(selected) - .style_for(mouse_state); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); Flex::row() .with_children(user.avatar.clone().map(|avatar| { Image::from_data(avatar) - .with_style(theme.contact_finder.contact_avatar) + .with_style(theme.contact_avatar) .aligned() .left() })) .with_child( Label::new(user.github_login.clone(), style.label.clone()) .contained() - .with_style(theme.contact_finder.contact_username) + .with_style(theme.contact_username) .aligned() .left(), ) @@ -150,7 +145,7 @@ impl PickerDelegate for ContactFinderDelegate { .contained() .with_style(style.container) .constrained() - .with_height(theme.contact_finder.row_height) + .with_height(theme.row_height) .into_any() } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 56b3b2d1560f41fc43b61b5d38659a2852d65835..6673efac2d26f418c21d5f973c12581b016d0691 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -44,13 +44,13 @@ pub struct Theme { pub workspace: Workspace, pub context_menu: ContextMenu, pub contacts_popover: ContactsPopover, - pub contact_list: ContactList, pub toolbar_dropdown_menu: DropdownMenu, pub copilot: Copilot, - pub contact_finder: ContactFinder, + pub collab_panel: CollabPanel, pub project_panel: ProjectPanel, pub channels_panel: ChanelsPanelStyle, pub command_palette: CommandPalette, + pub contact_finder: ContactFinder, pub picker: Picker, pub editor: Editor, pub search: Search, @@ -220,7 +220,7 @@ pub struct ContactsPopover { } #[derive(Deserialize, Default, JsonSchema)] -pub struct ContactList { +pub struct CollabPanel { pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, pub add_contact_button: IconButton, diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index d504f8e623ba9436167ec0c9f2f95f9cd8e2d6fe..6d7ed27884ebaf461021c7d55db012681b25f65c 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -1,4 +1,3 @@ -import contact_finder from "./contact_finder" import contacts_popover from "./contacts_popover" import command_palette from "./command_palette" import project_panel from "./project_panel" @@ -14,7 +13,8 @@ import simple_message_notification from "./simple_message_notification" import project_shared_notification from "./project_shared_notification" import tooltip from "./tooltip" import terminal from "./terminal" -import contact_list from "./contact_list" +import contact_finder from "./contact_finder" +import collab_panel from "./collab_panel" import toolbar_dropdown_menu from "./toolbar_dropdown_menu" import incoming_call_notification from "./incoming_call_notification" import welcome from "./welcome" @@ -49,8 +49,8 @@ export default function app(): any { project_panel: project_panel(), channels_panel: channels_panel(), contacts_popover: contacts_popover(), + collab_panel: collab_panel(), contact_finder: contact_finder(), - contact_list: contact_list(), toolbar_dropdown_menu: toolbar_dropdown_menu(), search: search(), shared_screen: shared_screen(), diff --git a/styles/src/style_tree/contact_list.ts b/styles/src/style_tree/collab_panel.ts similarity index 95% rename from styles/src/style_tree/contact_list.ts rename to styles/src/style_tree/collab_panel.ts index 1955231f59514847e59248c42f8d301895e38462..c457468e208e06e9301b201382f20092f4570c42 100644 --- a/styles/src/style_tree/contact_list.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -7,6 +7,7 @@ import { } from "./components" import { interactive, toggleable } from "../element" import { useTheme } from "../theme" + export default function contacts_panel(): any { const theme = useTheme() @@ -49,7 +50,7 @@ export default function contacts_panel(): any { } return { - background: background(layer), + // background: background(layer), padding: { top: 12 }, user_query_editor: { background: background(layer, "on"), @@ -88,7 +89,7 @@ export default function contacts_panel(): any { left: side_padding, right: side_padding, }, - background: background(layer, "default"), // posiewic: breaking change + // background: background(layer, "default"), // posiewic: breaking change }, state: { hovered: { @@ -97,7 +98,7 @@ export default function contacts_panel(): any { clicked: { background: background(layer, "pressed"), }, - }, // hack, we want headerRow to be interactive for whatever reason. It probably shouldn't be interactive in the first place. + }, }), state: { active: { @@ -220,7 +221,7 @@ export default function contacts_panel(): any { base: interactive({ base: { ...project_row, - background: background(layer), + // background: background(layer), icon: { margin: { left: name_margin }, color: foreground(layer, "variant"), From 14fdcadcfc638b229ca72a26102e24edd545ed6a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 25 Jul 2023 13:01:36 -0700 Subject: [PATCH 006/105] Add seemingly-redundant export in theme src file to workaround theme build error --- styles/src/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/styles/src/common.ts b/styles/src/common.ts index 054b2837914c89d82ffc584a1f5ba14e5a34f8ae..79fc23585fc630b9c90643dec4d500d4289e1963 100644 --- a/styles/src/common.ts +++ b/styles/src/common.ts @@ -1,5 +1,6 @@ import chroma from "chroma-js" export * from "./theme" +export * from "./theme/theme_config" export { chroma } export const font_families = { From fc491945351aa272946e2eb2e0b6e5dff394348c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 25 Jul 2023 17:29:09 -0700 Subject: [PATCH 007/105] Restructure collab panel, make contact finder into a normal modal --- crates/collab_ui/src/collab_titlebar_item.rs | 16 +- crates/collab_ui/src/panel.rs | 441 ++++++++++--------- crates/collab_ui/src/panel/contact_finder.rs | 2 +- crates/theme/src/theme.rs | 18 +- styles/src/style_tree/app.ts | 2 - styles/src/style_tree/collab_panel.ts | 87 +++- styles/src/style_tree/contacts_popover.ts | 9 - styles/src/style_tree/titlebar.ts | 4 + 8 files changed, 323 insertions(+), 256 deletions(-) diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 0d273fd1b813079e547c094950f7172b78e3a776..8a6bf5bc838bb3cab175ddf37fa490674a026743 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,7 +1,6 @@ use crate::{ - contact_notification::ContactNotification, face_pile::FacePile, - toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, - ToggleScreenSharing, + contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute, + toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing, }; use call::{ActiveCall, ParticipantLocation, Room}; use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore}; @@ -355,6 +354,7 @@ impl CollabTitlebarItem { user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx); }); } + fn render_branches_popover_host<'a>( &'a self, _theme: &'a theme::Titlebar, @@ -368,8 +368,8 @@ impl CollabTitlebarItem { .flex(1., true) .contained() .constrained() - .with_width(theme.contacts_popover.width) - .with_height(theme.contacts_popover.height) + .with_width(theme.titlebar.menu.width) + .with_height(theme.titlebar.menu.height) }) .on_click(MouseButton::Left, |_, _, _| {}) .on_down_out(MouseButton::Left, move |_, this, cx| { @@ -390,6 +390,7 @@ impl CollabTitlebarItem { .into_any() }) } + fn render_project_popover_host<'a>( &'a self, _theme: &'a theme::Titlebar, @@ -403,8 +404,8 @@ impl CollabTitlebarItem { .flex(1., true) .contained() .constrained() - .with_width(theme.contacts_popover.width) - .with_height(theme.contacts_popover.height) + .with_width(theme.titlebar.menu.width) + .with_height(theme.titlebar.menu.height) }) .on_click(MouseButton::Left, |_, _, _| {}) .on_down_out(MouseButton::Left, move |_, this, cx| { @@ -424,6 +425,7 @@ impl CollabTitlebarItem { .into_any() }) } + pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext) { if self.branch_popover.take().is_none() { if let Some(workspace) = self.workspace.upgrade(cx) { diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 4fee5f66f10b546094dd57198a4afe6e0337c385..e78f3ce22f92b63487405e547ae84796923f2194 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -4,7 +4,7 @@ mod panel_settings; use anyhow::Result; use call::ActiveCall; use client::{proto::PeerId, Client, Contact, User, UserStore}; -use contact_finder::{build_contact_finder, ContactFinder}; +use contact_finder::build_contact_finder; use context_menu::ContextMenu; use db::kvp::KEY_VALUE_STORE; use editor::{Cancel, Editor}; @@ -54,9 +54,6 @@ pub struct CollabPanel { has_focus: bool, pending_serialization: Task>, context_menu: ViewHandle, - contact_finder: Option>, - - // from contacts list filter_editor: ViewHandle, entries: Vec, selection: Option, @@ -84,14 +81,16 @@ pub enum Event { #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, + Channels, Requests, + Contacts, Online, Offline, } #[derive(Clone)] enum ContactEntry { - Header(Section), + Header(Section, usize), CallParticipant { user: Arc, is_pending: bool, @@ -130,7 +129,7 @@ impl CollabPanel { })), cx, ); - editor.set_placeholder_text("Filter contacts", cx); + editor.set_placeholder_text("Filter channels, contacts", cx); editor }); @@ -145,7 +144,7 @@ impl CollabPanel { this.selection = this .entries .iter() - .position(|entry| !matches!(entry, ContactEntry::Header(_))); + .position(|entry| !matches!(entry, ContactEntry::Header(_, _))); } } }) @@ -158,11 +157,12 @@ impl CollabPanel { let current_project_id = this.project.read(cx).remote_id(); match &this.entries[ix] { - ContactEntry::Header(section) => { + ContactEntry::Header(section, depth) => { let is_collapsed = this.collapsed_sections.contains(section); Self::render_header( *section, - &theme.collab_panel, + &theme, + *depth, is_selected, is_collapsed, cx, @@ -234,7 +234,6 @@ impl CollabPanel { pending_serialization: Task::ready(None), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), filter_editor, - contact_finder: None, entries: Vec::default(), selection: None, user_store: workspace.user_store().clone(), @@ -431,128 +430,137 @@ impl CollabPanel { })); if !participant_entries.is_empty() { - self.entries.push(ContactEntry::Header(Section::ActiveCall)); + self.entries + .push(ContactEntry::Header(Section::ActiveCall, 0)); if !self.collapsed_sections.contains(&Section::ActiveCall) { self.entries.extend(participant_entries); } } } - let mut request_entries = Vec::new(); - let incoming = user_store.incoming_contact_requests(); - if !incoming.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - incoming - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), + self.entries + .push(ContactEntry::Header(Section::Channels, 0)); + + self.entries + .push(ContactEntry::Header(Section::Contacts, 0)); + + if !self.collapsed_sections.contains(&Section::Contacts) { + let mut request_entries = Vec::new(); + let incoming = user_store.incoming_contact_requests(); + if !incoming.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + incoming + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches.iter().map(|mat| { + ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone()) + }), ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), - ); - } + } - let outgoing = user_store.outgoing_contact_requests(); - if !outgoing.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - outgoing - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), + let outgoing = user_store.outgoing_contact_requests(); + if !outgoing.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + outgoing + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches.iter().map(|mat| { + ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone()) + }), ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), - ); - } + } - if !request_entries.is_empty() { - self.entries.push(ContactEntry::Header(Section::Requests)); - if !self.collapsed_sections.contains(&Section::Requests) { - self.entries.append(&mut request_entries); + if !request_entries.is_empty() { + self.entries + .push(ContactEntry::Header(Section::Requests, 1)); + if !self.collapsed_sections.contains(&Section::Requests) { + self.entries.append(&mut request_entries); + } } - } - let contacts = user_store.contacts(); - if !contacts.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - contacts - .iter() - .enumerate() - .map(|(ix, contact)| StringMatchCandidate { + let contacts = user_store.contacts(); + if !contacts.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend(contacts.iter().enumerate().map(|(ix, contact)| { + StringMatchCandidate { id: ix, string: contact.user.github_login.clone(), char_bag: contact.user.github_login.chars().collect(), - }), - ); + } + })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); - let (mut online_contacts, offline_contacts) = matches - .iter() - .partition::, _>(|mat| contacts[mat.candidate_id].online); - if let Some(room) = ActiveCall::global(cx).read(cx).room() { - let room = room.read(cx); - online_contacts.retain(|contact| { - let contact = &contacts[contact.candidate_id]; - !room.contains_participant(contact.user.id) - }); - } + let (mut online_contacts, offline_contacts) = matches + .iter() + .partition::, _>(|mat| contacts[mat.candidate_id].online); + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let room = room.read(cx); + online_contacts.retain(|contact| { + let contact = &contacts[contact.candidate_id]; + !room.contains_participant(contact.user.id) + }); + } - for (matches, section) in [ - (online_contacts, Section::Online), - (offline_contacts, Section::Offline), - ] { - if !matches.is_empty() { - self.entries.push(ContactEntry::Header(section)); - if !self.collapsed_sections.contains(§ion) { - let active_call = &ActiveCall::global(cx).read(cx); - for mat in matches { - let contact = &contacts[mat.candidate_id]; - self.entries.push(ContactEntry::Contact { - contact: contact.clone(), - calling: active_call.pending_invites().contains(&contact.user.id), - }); + for (matches, section) in [ + (online_contacts, Section::Online), + (offline_contacts, Section::Offline), + ] { + if !matches.is_empty() { + self.entries.push(ContactEntry::Header(section, 1)); + if !self.collapsed_sections.contains(§ion) { + let active_call = &ActiveCall::global(cx).read(cx); + for mat in matches { + let contact = &contacts[mat.candidate_id]; + self.entries.push(ContactEntry::Contact { + contact: contact.clone(), + calling: active_call + .pending_invites() + .contains(&contact.user.id), + }); + } } } } @@ -865,7 +873,8 @@ impl CollabPanel { fn render_header( section: Section, - theme: &theme::CollabPanel, + theme: &theme::Theme, + depth: usize, is_selected: bool, is_collapsed: bool, cx: &mut ViewContext, @@ -873,69 +882,112 @@ impl CollabPanel { enum Header {} enum LeaveCallContactList {} - let header_style = theme - .header_row - .in_state(is_selected) - .style_for(&mut Default::default()); + let tooltip_style = &theme.tooltip; let text = match section { - Section::ActiveCall => "Collaborators", - Section::Requests => "Contact Requests", + Section::ActiveCall => "Current Call", + Section::Requests => "Requests", + Section::Contacts => "Contacts", + Section::Channels => "Channels", Section::Online => "Online", Section::Offline => "Offline", }; - let leave_call = if section == Section::ActiveCall { - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.leave_call.style_for(state); - Label::new("Leave Call", style.text.clone()) - .contained() - .with_style(style.container) + + enum AddContact {} + let button = match section { + Section::ActiveCall => Some( + MouseEventHandler::::new(0, cx, |_, _| { + render_icon_button( + &theme.collab_panel.leave_call_button, + "icons/radix/exit.svg", + ) }) + .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, |_, _, cx| { ActiveCall::global(cx) .update(cx, |call, cx| call.hang_up(cx)) .detach_and_log_err(cx); }) - .aligned(), - ) - } else { - None + .with_tooltip::( + 0, + "Leave call".into(), + None, + tooltip_style.clone(), + cx, + ), + ), + Section::Contacts => Some( + MouseEventHandler::::new(0, cx, |_, _| { + render_icon_button( + &theme.collab_panel.add_contact_button, + "icons/user_plus_16.svg", + ) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, this, cx| { + this.toggle_contact_finder(cx); + }) + .with_tooltip::( + 0, + "Search for new contact".into(), + None, + tooltip_style.clone(), + cx, + ), + ), + _ => None, }; - let icon_size = theme.section_icon_size; - MouseEventHandler::::new(section as usize, cx, |_, _| { + let can_collapse = depth > 0; + let icon_size = (&theme.collab_panel).section_icon_size; + MouseEventHandler::::new(section as usize, cx, |state, _| { + let header_style = if depth > 0 { + &theme.collab_panel.subheader_row + } else { + &theme.collab_panel.header_row + } + .in_state(is_selected) + .style_for(state); + Flex::row() - .with_child( - Svg::new(if is_collapsed { - "icons/chevron_right_8.svg" - } else { - "icons/chevron_down_8.svg" - }) - .with_color(header_style.text.color) - .constrained() - .with_max_width(icon_size) - .with_max_height(icon_size) - .aligned() - .constrained() - .with_width(icon_size), - ) + .with_children(if can_collapse { + Some( + Svg::new(if is_collapsed { + "icons/chevron_right_8.svg" + } else { + "icons/chevron_down_8.svg" + }) + .with_color(header_style.text.color) + .constrained() + .with_max_width(icon_size) + .with_max_height(icon_size) + .aligned() + .constrained() + .with_width(icon_size) + .contained() + .with_margin_right( + theme.collab_panel.contact_username.container.margin.left, + ), + ) + } else { + None + }) .with_child( Label::new(text, header_style.text.clone()) .aligned() .left() - .contained() - .with_margin_left(theme.contact_username.container.margin.left) .flex(1., true), ) - .with_children(leave_call) + .with_children(button.map(|button| button.aligned().right())) .constrained() - .with_height(theme.row_height) + .with_height(theme.collab_panel.row_height) .contained() .with_style(header_style.container) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, this, cx| { - this.toggle_expanded(section, cx); + if can_collapse { + this.toggle_expanded(section, cx); + } }) .into_any() } @@ -954,7 +1006,7 @@ impl CollabPanel { let github_login = contact.user.github_login.clone(); let initial_project = project.clone(); let mut event_handler = - MouseEventHandler::::new(contact.user.id as usize, cx, |_, cx| { + MouseEventHandler::::new(contact.user.id as usize, cx, |state, cx| { Flex::row() .with_children(contact.user.avatar.clone().map(|avatar| { let status_badge = if contact.online { @@ -1023,12 +1075,7 @@ impl CollabPanel { .constrained() .with_height(theme.row_height) .contained() - .with_style( - *theme - .contact_row - .in_state(is_selected) - .style_for(&mut Default::default()), - ) + .with_style(*theme.contact_row.in_state(is_selected).style_for(state)) }) .on_click(MouseButton::Left, move |_, this, cx| { if online && !busy { @@ -1147,11 +1194,6 @@ impl CollabPanel { } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - if self.contact_finder.take().is_some() { - cx.notify(); - return; - } - let did_clear = self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx) > 0 { editor.set_text("", cx); @@ -1206,7 +1248,7 @@ impl CollabPanel { if let Some(selection) = self.selection { if let Some(entry) = self.entries.get(selection) { match entry { - ContactEntry::Header(section) => { + ContactEntry::Header(section, _) => { self.toggle_expanded(*section, cx); } ContactEntry::Contact { contact, calling } => { @@ -1253,19 +1295,17 @@ impl CollabPanel { } fn toggle_contact_finder(&mut self, cx: &mut ViewContext) { - if self.contact_finder.take().is_none() { - let child = cx.add_view(|cx| { - let finder = build_contact_finder(self.user_store.clone(), cx); - finder.set_query(self.filter_editor.read(cx).text(cx), cx); - finder + if let Some(workspace) = self.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(cx, |_, cx| { + cx.add_view(|cx| { + let finder = build_contact_finder(self.user_store.clone(), cx); + finder.set_query(self.filter_editor.read(cx).text(cx), cx); + finder + }) + }); }); - cx.focus(&child); - // self.subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { - // // PickerEvent::Dismiss => cx.emit(Event::Dismissed), - // })); - self.contact_finder = Some(child); } - cx.notify(); } fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext) { @@ -1338,44 +1378,19 @@ impl View for CollabPanel { } fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { - enum AddContact {} - let theme = theme::current(cx).clone(); + let theme = &theme::current(cx).collab_panel; Stack::new() - .with_child(if let Some(finder) = &self.contact_finder { - ChildView::new(&finder, cx).into_any() - } else { + .with_child( Flex::column() .with_child( Flex::row() .with_child( ChildView::new(&self.filter_editor, cx) .contained() - .with_style(theme.collab_panel.user_query_editor.container) + .with_style(theme.user_query_editor.container) .flex(1.0, true), ) - .with_child( - MouseEventHandler::::new(0, cx, |_, _| { - render_icon_button( - &theme.collab_panel.add_contact_button, - "icons/user_plus_16.svg", - ) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, |_, this, cx| { - this.toggle_contact_finder(cx); - }) - .with_tooltip::( - 0, - "Search for new contact".into(), - None, - theme.tooltip.clone(), - cx, - ) - .constrained() - .with_height(theme.collab_panel.user_query_editor_height) - .with_width(theme.collab_panel.user_query_editor_height), - ) .constrained() .with_width(self.size(cx)), ) @@ -1386,10 +1401,12 @@ impl View for CollabPanel { .flex(1., true) .into_any(), ) + .contained() + .with_style(theme.container) .constrained() .with_width(self.size(cx)) - .into_any() - }) + .into_any(), + ) .with_child(ChildView::new(&self.context_menu, cx)) .into_any_named("channels panel") .into_any() @@ -1457,9 +1474,9 @@ impl Panel for CollabPanel { impl PartialEq for ContactEntry { fn eq(&self, other: &Self) -> bool { match self { - ContactEntry::Header(section_1) => { - if let ContactEntry::Header(section_2) = other { - return section_1 == section_2; + ContactEntry::Header(section_1, depth_1) => { + if let ContactEntry::Header(section_2, depth_2) = other { + return section_1 == section_2 && depth_1 == depth_2; } } ContactEntry::CallParticipant { user: user_1, .. } => { @@ -1520,9 +1537,9 @@ fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Elemen .constrained() .with_width(style.icon_width) .aligned() - .contained() - .with_style(style.container) .constrained() .with_width(style.button_width) .with_height(style.button_width) + .contained() + .with_style(style.container) } diff --git a/crates/collab_ui/src/panel/contact_finder.rs b/crates/collab_ui/src/panel/contact_finder.rs index a5868f8d2fbc4e01a6650b49e16d6f167453140b..41fff2af4384147e49950b1026b4c553bfec2b24 100644 --- a/crates/collab_ui/src/panel/contact_finder.rs +++ b/crates/collab_ui/src/panel/contact_finder.rs @@ -22,7 +22,7 @@ pub fn build_contact_finder( }, cx, ) - .with_theme(|theme| theme.contact_finder.picker.clone()) + .with_theme(|theme| theme.picker.clone()) } pub struct ContactFinderDelegate { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 6673efac2d26f418c21d5f973c12581b016d0691..c06a71d2dbbbdab1d186666a5f3ff27b0db53177 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -43,7 +43,6 @@ pub struct Theme { pub meta: ThemeMeta, pub workspace: Workspace, pub context_menu: ContextMenu, - pub contacts_popover: ContactsPopover, pub toolbar_dropdown_menu: DropdownMenu, pub copilot: Copilot, pub collab_panel: CollabPanel, @@ -118,6 +117,7 @@ pub struct Titlebar { #[serde(flatten)] pub container: ContainerStyle, pub height: f32, + pub menu: TitlebarMenu, pub project_menu_button: Toggleable>, pub project_name_divider: ContainedText, pub git_menu_button: Toggleable>, @@ -144,6 +144,12 @@ pub struct Titlebar { pub user_menu: UserMenu, } +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct TitlebarMenu { + pub width: f32, + pub height: f32, +} + #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct UserMenu { pub user_menu_button_online: UserMenuButton, @@ -212,19 +218,15 @@ pub struct CopilotAuthAuthorized { } #[derive(Deserialize, Default, JsonSchema)] -pub struct ContactsPopover { +pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, - pub height: f32, - pub width: f32, -} - -#[derive(Deserialize, Default, JsonSchema)] -pub struct CollabPanel { pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, + pub leave_call_button: IconButton, pub add_contact_button: IconButton, pub header_row: Toggleable>, + pub subheader_row: Toggleable>, pub leave_call: Interactive, pub contact_row: Toggleable>, pub row_height: f32, diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index 6d7ed27884ebaf461021c7d55db012681b25f65c..d017ce90cad387463b8b8f736881bcf451ad30c8 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -1,4 +1,3 @@ -import contacts_popover from "./contacts_popover" import command_palette from "./command_palette" import project_panel from "./project_panel" import search from "./search" @@ -48,7 +47,6 @@ export default function app(): any { project_diagnostics: project_diagnostics(), project_panel: project_panel(), channels_panel: channels_panel(), - contacts_popover: contacts_popover(), collab_panel: collab_panel(), contact_finder: contact_finder(), toolbar_dropdown_menu: toolbar_dropdown_menu(), diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index c457468e208e06e9301b201382f20092f4570c42..39ee9b610f5d7c8a60b418ec69afc293ffeaaa03 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -50,8 +50,10 @@ export default function contacts_panel(): any { } return { - // background: background(layer), - padding: { top: 12 }, + background: background(layer), + padding: { + top: 12, + }, user_query_editor: { background: background(layer, "on"), corner_radius: 6, @@ -68,12 +70,17 @@ export default function contacts_panel(): any { top: 4, }, margin: { - left: 6, + left: side_padding, + right: side_padding, }, }, user_query_editor_height: 33, add_contact_button: { - margin: { left: 6, right: 12 }, + color: foreground(layer, "on"), + button_width: 28, + icon_width: 16, + }, + leave_call_button: { color: foreground(layer, "on"), button_width: 28, icon_width: 16, @@ -83,13 +90,46 @@ export default function contacts_panel(): any { header_row: toggleable({ base: interactive({ base: { - ...text(layer, "mono", { size: "sm" }), + ...text(layer, "mono", { size: "sm", weight: "bold" }), margin: { top: 14 }, padding: { left: side_padding, right: side_padding, }, - // background: background(layer, "default"), // posiewic: breaking change + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }), + state: { + active: { + default: { + ...text(layer, "mono", "active", { size: "sm" }), + background: background(layer, "active"), + }, + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }, + }), + subheader_row: toggleable({ + base: interactive({ + base: { + ...text(layer, "mono", { size: "sm" }), + // margin: { top: 14 }, + padding: { + left: side_padding, + right: side_padding, + }, }, state: { hovered: { @@ -139,25 +179,38 @@ export default function contacts_panel(): any { }, }, }), - contact_row: { - inactive: { - default: { + contact_row: toggleable({ + base: interactive({ + base: { padding: { left: side_padding, right: side_padding, }, }, - }, - active: { - default: { - background: background(layer, "active"), - padding: { - left: side_padding, - right: side_padding, + state: { + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }), + state: { + active: { + default: { + ...text(layer, "mono", "active", { size: "sm" }), + background: background(layer, "active"), + }, + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), }, }, }, - }, + }), contact_avatar: { corner_radius: 10, width: 18, diff --git a/styles/src/style_tree/contacts_popover.ts b/styles/src/style_tree/contacts_popover.ts index 7ca258d1662d921cbba9d347b7022b8be65c2dd0..0e76bbb38a702cdc44542d305bc819ca87e9ff6a 100644 --- a/styles/src/style_tree/contacts_popover.ts +++ b/styles/src/style_tree/contacts_popover.ts @@ -4,13 +4,4 @@ import { background, border } from "./components" export default function contacts_popover(): any { const theme = useTheme() - return { - // background: background(theme.middle), - // corner_radius: 6, - padding: { top: 6, bottom: 6 }, - // shadow: theme.popover_shadow, - border: border(theme.middle), - width: 300, - height: 400, - } } diff --git a/styles/src/style_tree/titlebar.ts b/styles/src/style_tree/titlebar.ts index fe0c53e87dac61bdfc93688eea13e74d583017a9..a93bf376c02921f2f32473368c3778138bfc7a7e 100644 --- a/styles/src/style_tree/titlebar.ts +++ b/styles/src/style_tree/titlebar.ts @@ -178,6 +178,10 @@ export function titlebar(): any { left: 80, right: 0, }, + menu: { + width: 300, + height: 400, + }, // Project project_name_divider: text(theme.lowest, "sans", "variant"), From 4a088fc4aeb24401c8d011109f3430229934a056 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 25 Jul 2023 18:00:49 -0700 Subject: [PATCH 008/105] Make major collab panel headers non-interactive --- crates/collab_ui/src/panel.rs | 258 ++++++++++++++------------ crates/theme/src/theme.rs | 2 +- styles/src/style_tree/collab_panel.ts | 41 +--- 3 files changed, 146 insertions(+), 155 deletions(-) diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index e78f3ce22f92b63487405e547ae84796923f2194..bf0397ec765ee2a52143313f7e67dc8cf05aac97 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -444,123 +444,122 @@ impl CollabPanel { self.entries .push(ContactEntry::Header(Section::Contacts, 0)); - if !self.collapsed_sections.contains(&Section::Contacts) { - let mut request_entries = Vec::new(); - let incoming = user_store.incoming_contact_requests(); - if !incoming.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - incoming - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches.iter().map(|mat| { - ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone()) - }), + let mut request_entries = Vec::new(); + let incoming = user_store.incoming_contact_requests(); + if !incoming.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + incoming + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), ); - } + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), + ); + } - let outgoing = user_store.outgoing_contact_requests(); - if !outgoing.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - outgoing - .iter() - .enumerate() - .map(|(ix, user)| StringMatchCandidate { - id: ix, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches.iter().map(|mat| { - ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone()) - }), + let outgoing = user_store.outgoing_contact_requests(); + if !outgoing.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + outgoing + .iter() + .enumerate() + .map(|(ix, user)| StringMatchCandidate { + id: ix, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }), ); - } + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches + .iter() + .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), + ); + } - if !request_entries.is_empty() { - self.entries - .push(ContactEntry::Header(Section::Requests, 1)); - if !self.collapsed_sections.contains(&Section::Requests) { - self.entries.append(&mut request_entries); - } + if !request_entries.is_empty() { + self.entries + .push(ContactEntry::Header(Section::Requests, 1)); + if !self.collapsed_sections.contains(&Section::Requests) { + self.entries.append(&mut request_entries); } + } - let contacts = user_store.contacts(); - if !contacts.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend(contacts.iter().enumerate().map(|(ix, contact)| { - StringMatchCandidate { + let contacts = user_store.contacts(); + if !contacts.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + contacts + .iter() + .enumerate() + .map(|(ix, contact)| StringMatchCandidate { id: ix, string: contact.user.github_login.clone(), char_bag: contact.user.github_login.chars().collect(), - } - })); + }), + ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); - let (mut online_contacts, offline_contacts) = matches - .iter() - .partition::, _>(|mat| contacts[mat.candidate_id].online); - if let Some(room) = ActiveCall::global(cx).read(cx).room() { - let room = room.read(cx); - online_contacts.retain(|contact| { - let contact = &contacts[contact.candidate_id]; - !room.contains_participant(contact.user.id) - }); - } + let (mut online_contacts, offline_contacts) = matches + .iter() + .partition::, _>(|mat| contacts[mat.candidate_id].online); + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let room = room.read(cx); + online_contacts.retain(|contact| { + let contact = &contacts[contact.candidate_id]; + !room.contains_participant(contact.user.id) + }); + } - for (matches, section) in [ - (online_contacts, Section::Online), - (offline_contacts, Section::Offline), - ] { - if !matches.is_empty() { - self.entries.push(ContactEntry::Header(section, 1)); - if !self.collapsed_sections.contains(§ion) { - let active_call = &ActiveCall::global(cx).read(cx); - for mat in matches { - let contact = &contacts[mat.candidate_id]; - self.entries.push(ContactEntry::Contact { - contact: contact.clone(), - calling: active_call - .pending_invites() - .contains(&contact.user.id), - }); - } + for (matches, section) in [ + (online_contacts, Section::Online), + (offline_contacts, Section::Offline), + ] { + if !matches.is_empty() { + self.entries.push(ContactEntry::Header(section, 1)); + if !self.collapsed_sections.contains(§ion) { + let active_call = &ActiveCall::global(cx).read(cx); + for mat in matches { + let contact = &contacts[mat.candidate_id]; + self.entries.push(ContactEntry::Contact { + contact: contact.clone(), + calling: active_call.pending_invites().contains(&contact.user.id), + }); } } } @@ -940,13 +939,15 @@ impl CollabPanel { let can_collapse = depth > 0; let icon_size = (&theme.collab_panel).section_icon_size; MouseEventHandler::::new(section as usize, cx, |state, _| { - let header_style = if depth > 0 { - &theme.collab_panel.subheader_row + let header_style = if can_collapse { + theme + .collab_panel + .subheader_row + .in_state(is_selected) + .style_for(state) } else { &theme.collab_panel.header_row - } - .in_state(is_selected) - .style_for(state); + }; Flex::row() .with_children(if can_collapse { @@ -1209,13 +1210,15 @@ impl CollabPanel { } fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - if let Some(ix) = self.selection { - if self.entries.len() > ix + 1 { - self.selection = Some(ix + 1); + let mut ix = self.selection.map_or(0, |ix| ix + 1); + while let Some(entry) = self.entries.get(ix) { + if entry.is_selectable() { + self.selection = Some(ix); + break; } - } else if !self.entries.is_empty() { - self.selection = Some(0); + ix += 1; } + self.list_state.reset(self.entries.len()); if let Some(ix) = self.selection { self.list_state.scroll_to(ListOffset { @@ -1227,13 +1230,18 @@ impl CollabPanel { } fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if let Some(ix) = self.selection { - if ix > 0 { - self.selection = Some(ix - 1); - } else { - self.selection = None; + if let Some(mut ix) = self.selection.take() { + while ix > 0 { + ix -= 1; + if let Some(entry) = self.entries.get(ix) { + if entry.is_selectable() { + self.selection = Some(ix); + break; + } + } } } + self.list_state.reset(self.entries.len()); if let Some(ix) = self.selection { self.list_state.scroll_to(ListOffset { @@ -1471,6 +1479,16 @@ impl Panel for CollabPanel { } } +impl ContactEntry { + fn is_selectable(&self) -> bool { + if let ContactEntry::Header(_, 0) = self { + false + } else { + true + } + } +} + impl PartialEq for ContactEntry { fn eq(&self, other: &Self) -> bool { match self { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index c06a71d2dbbbdab1d186666a5f3ff27b0db53177..e13c8daafcc87cbc7bd3437d98d64d3bcebad489 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -225,7 +225,7 @@ pub struct CollabPanel { pub user_query_editor_height: f32, pub leave_call_button: IconButton, pub add_contact_button: IconButton, - pub header_row: Toggleable>, + pub header_row: ContainedText, pub subheader_row: Toggleable>, pub leave_call: Interactive, pub contact_row: Toggleable>, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 39ee9b610f5d7c8a60b418ec69afc293ffeaaa03..4f847081abbf7a769eb8f106956e5059798f4ce1 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -87,45 +87,18 @@ export default function contacts_panel(): any { }, row_height: 28, section_icon_size: 8, - header_row: toggleable({ - base: interactive({ - base: { - ...text(layer, "mono", { size: "sm", weight: "bold" }), - margin: { top: 14 }, - padding: { - left: side_padding, - right: side_padding, - }, - }, - state: { - hovered: { - background: background(layer, "hovered"), - }, - clicked: { - background: background(layer, "pressed"), - }, - }, - }), - state: { - active: { - default: { - ...text(layer, "mono", "active", { size: "sm" }), - background: background(layer, "active"), - }, - hovered: { - background: background(layer, "hovered"), - }, - clicked: { - background: background(layer, "pressed"), - }, - }, + header_row: { + ...text(layer, "mono", { size: "sm", weight: "bold" }), + margin: { top: 14 }, + padding: { + left: side_padding, + right: side_padding, }, - }), + }, subheader_row: toggleable({ base: interactive({ base: { ...text(layer, "mono", { size: "sm" }), - // margin: { top: 14 }, padding: { left: side_padding, right: side_padding, From 1549c2274f3160a99199efb53b47faafa4182625 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 26 Jul 2023 11:11:48 -0700 Subject: [PATCH 009/105] Create channel adding modal --- crates/collab_ui/src/panel.rs | 101 ++++++++++++++++-------- crates/theme/src/theme.rs | 13 +-- styles/src/style_tree/app.ts | 2 - styles/src/style_tree/channels_panel.ts | 12 --- styles/src/style_tree/collab_panel.ts | 5 ++ 5 files changed, 76 insertions(+), 57 deletions(-) delete mode 100644 styles/src/style_tree/channels_panel.ts diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bf0397ec765ee2a52143313f7e67dc8cf05aac97..bc79694d53eb04b86be14f3ae737e1a8bc6ef6ff 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -1,3 +1,4 @@ +mod channel_modal; mod contact_finder; mod panel_settings; @@ -16,7 +17,10 @@ use gpui::{ Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState, MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg, }, - geometry::{rect::RectF, vector::vec2f}, + geometry::{ + rect::RectF, + vector::vec2f, + }, platform::{CursorStyle, MouseButton, PromptLevel}, serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, @@ -34,6 +38,8 @@ use workspace::{ Workspace, }; +use self::channel_modal::ChannelModal; + actions!(collab_panel, [ToggleFocus]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; @@ -41,6 +47,7 @@ const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; pub fn init(_client: Arc, cx: &mut AppContext) { settings::register::(cx); contact_finder::init(cx); + channel_modal::init(cx); cx.add_action(CollabPanel::cancel); cx.add_action(CollabPanel::select_next); @@ -880,6 +887,7 @@ impl CollabPanel { ) -> AnyElement { enum Header {} enum LeaveCallContactList {} + enum AddChannel {} let tooltip_style = &theme.tooltip; let text = match section { @@ -933,6 +941,22 @@ impl CollabPanel { cx, ), ), + Section::Channels => Some( + MouseEventHandler::::new(0, cx, |_, _| { + render_icon_button(&theme.collab_panel.add_contact_button, "icons/plus_16.svg") + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, |_, this, cx| { + this.toggle_channel_finder(cx); + }) + .with_tooltip::( + 0, + "Add or join a channel".into(), + None, + tooltip_style.clone(), + cx, + ), + ), _ => None, }; @@ -1316,6 +1340,14 @@ impl CollabPanel { } } + fn toggle_channel_finder(&mut self, cx: &mut ViewContext) { + if let Some(workspace) = self.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(cx, |_, cx| cx.add_view(|cx| ChannelModal::new(cx))); + }); + } + } + fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext) { let user_store = self.user_store.clone(); let prompt_message = format!( @@ -1388,36 +1420,43 @@ impl View for CollabPanel { fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { let theme = &theme::current(cx).collab_panel; - Stack::new() - .with_child( - Flex::column() - .with_child( - Flex::row() - .with_child( - ChildView::new(&self.filter_editor, cx) - .contained() - .with_style(theme.user_query_editor.container) - .flex(1.0, true), - ) - .constrained() - .with_width(self.size(cx)), - ) - .with_child( - List::new(self.list_state.clone()) - .constrained() - .with_width(self.size(cx)) - .flex(1., true) - .into_any(), - ) - .contained() - .with_style(theme.container) - .constrained() - .with_width(self.size(cx)) - .into_any(), - ) - .with_child(ChildView::new(&self.context_menu, cx)) - .into_any_named("channels panel") - .into_any() + enum PanelFocus {} + MouseEventHandler::::new(0, cx, |_, cx| { + Stack::new() + .with_child( + Flex::column() + .with_child( + Flex::row() + .with_child( + ChildView::new(&self.filter_editor, cx) + .contained() + .with_style(theme.user_query_editor.container) + .flex(1.0, true), + ) + .constrained() + .with_width(self.size(cx)), + ) + .with_child( + List::new(self.list_state.clone()) + .constrained() + .with_width(self.size(cx)) + .flex(1., true) + .into_any(), + ) + .contained() + .with_style(theme.container) + .constrained() + .with_width(self.size(cx)) + .into_any(), + ) + .with_child(ChildView::new(&self.context_menu, cx)) + .into_any() + }) + .on_click(MouseButton::Left, |_, v, cx| { + cx.focus_self() + }) + .into_any_named("channels panel") + } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index e13c8daafcc87cbc7bd3437d98d64d3bcebad489..3de878118e6e0be365ecbeec034fa78445ad9758 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -47,7 +47,6 @@ pub struct Theme { pub copilot: Copilot, pub collab_panel: CollabPanel, pub project_panel: ProjectPanel, - pub channels_panel: ChanelsPanelStyle, pub command_palette: CommandPalette, pub contact_finder: ContactFinder, pub picker: Picker, @@ -225,6 +224,7 @@ pub struct CollabPanel { pub user_query_editor_height: f32, pub leave_call_button: IconButton, pub add_contact_button: IconButton, + pub add_channel_button: IconButton, pub header_row: ContainedText, pub subheader_row: Toggleable>, pub leave_call: Interactive, @@ -1064,17 +1064,6 @@ pub struct Contained { contained: T, } -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct FlexStyle { - // Between item spacing - item_spacing: f32, -} - -#[derive(Clone, Deserialize, Default, JsonSchema)] -pub struct ChanelsPanelStyle { - pub contacts_header: TextStyle, -} - #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct SavedConversation { pub container: Interactive, diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index d017ce90cad387463b8b8f736881bcf451ad30c8..fab751d0d1ae29a9ea4b3d4ec3c13049caea1e09 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -23,7 +23,6 @@ import { titlebar } from "./titlebar" import editor from "./editor" import feedback from "./feedback" import { useTheme } from "../common" -import channels_panel from "./channels_panel" export default function app(): any { const theme = useTheme() @@ -46,7 +45,6 @@ export default function app(): any { editor: editor(), project_diagnostics: project_diagnostics(), project_panel: project_panel(), - channels_panel: channels_panel(), collab_panel: collab_panel(), contact_finder: contact_finder(), toolbar_dropdown_menu: toolbar_dropdown_menu(), diff --git a/styles/src/style_tree/channels_panel.ts b/styles/src/style_tree/channels_panel.ts deleted file mode 100644 index 126bbbe18cad17f3783c403748c55c852528548a..0000000000000000000000000000000000000000 --- a/styles/src/style_tree/channels_panel.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - text, -} from "./components" -import { useTheme } from "../theme" -export default function channels_panel(): any { - const theme = useTheme() - - - return { - contacts_header: text(theme.middle, "sans", "variant", { size: "lg" }), - } -} diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 4f847081abbf7a769eb8f106956e5059798f4ce1..8e817add3ffa39f0df0761726f9dfc7a0bd9baaf 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -80,6 +80,11 @@ export default function contacts_panel(): any { button_width: 28, icon_width: 16, }, + add_channel_button: { + color: foreground(layer, "on"), + button_width: 28, + icon_width: 16, + }, leave_call_button: { color: foreground(layer, "on"), button_width: 28, From 40c293e184a40a054516250045528fd5a3d20c21 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 26 Jul 2023 15:50:01 -0700 Subject: [PATCH 010/105] Add channel_modal file --- crates/collab_ui/src/panel/channel_modal.rs | 95 +++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 crates/collab_ui/src/panel/channel_modal.rs diff --git a/crates/collab_ui/src/panel/channel_modal.rs b/crates/collab_ui/src/panel/channel_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..562536d58cd3863b1d57fc92ae33a1469fb4938f --- /dev/null +++ b/crates/collab_ui/src/panel/channel_modal.rs @@ -0,0 +1,95 @@ +use editor::Editor; +use gpui::{elements::*, AnyViewHandle, Entity, View, ViewContext, ViewHandle, AppContext}; +use menu::Cancel; +use workspace::{item::ItemHandle, Modal}; + +pub fn init(cx: &mut AppContext) { + cx.add_action(ChannelModal::cancel) +} + +pub struct ChannelModal { + has_focus: bool, + input_editor: ViewHandle, +} + +pub enum Event { + Dismiss, +} + +impl Entity for ChannelModal { + type Event = Event; +} + +impl ChannelModal { + pub fn new(cx: &mut ViewContext) -> Self { + let input_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line(None, cx); + editor.set_placeholder_text("Create or add a channel", cx); + editor + }); + + ChannelModal { + has_focus: false, + input_editor, + } + } + + pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + self.dismiss(cx); + } + + fn dismiss(&mut self, cx: &mut ViewContext) { + cx.emit(Event::Dismiss) + } +} + +impl View for ChannelModal { + fn ui_name() -> &'static str { + "Channel Modal" + } + + fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { + let style = theme::current(cx).editor.hint_diagnostic.message.clone(); + let modal_container = theme::current(cx).picker.container.clone(); + + enum ChannelModal {} + MouseEventHandler::::new(0, cx, |_, cx| { + Flex::column() + .with_child(ChildView::new(self.input_editor.as_any(), cx)) + .with_child(Label::new("ADD OR BROWSE CHANNELS HERE", style)) + .contained() + .with_style(modal_container) + .constrained() + .with_max_width(540.) + .with_max_height(420.) + + }) + .on_click(gpui::platform::MouseButton::Left, |_, _, _| {}) // Capture click and down events + .on_down_out(gpui::platform::MouseButton::Left, |_, v, cx| { + v.dismiss(cx) + }).into_any_named("channel modal") + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + self.has_focus = true; + if cx.is_self_focused() { + cx.focus(&self.input_editor); + } + } + + fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + 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 { + Event::Dismiss => true, + } + } +} From bb70901e715afecdc2d7652e4df81644180b4025 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 26 Jul 2023 17:20:43 -0700 Subject: [PATCH 011/105] WIP --- .../20221109000000_test_schema.sql | 26 ++++ .../20230727150500_add_channels.sql | 19 +++ crates/collab/src/db.rs | 139 +++++++++++++++++- crates/collab/src/db/channel.rs | 39 +++++ crates/collab/src/db/channel_member.rs | 59 ++++++++ crates/collab/src/db/channel_parent.rs | 13 ++ crates/collab/src/db/room.rs | 6 + crates/collab/src/db/user.rs | 8 + crates/collab_ui/src/panel.rs | 10 +- 9 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 crates/collab/migrations/20230727150500_add_channels.sql create mode 100644 crates/collab/src/db/channel.rs create mode 100644 crates/collab/src/db/channel_member.rs create mode 100644 crates/collab/src/db/channel_parent.rs diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index c690b6148ad120c41f9441bb95b5911b7e83e0ca..a446f6b44025da5d3dda67cdf9c94ca5bb092697 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -184,3 +184,29 @@ CREATE UNIQUE INDEX "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); + +CREATE TABLE "channels" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + -- "id_path" TEXT NOT NULL, + "name" VARCHAR NOT NULL, + "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now +) + +CREATE TABLE "channel_parents" ( + "child_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "parent_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + PRIMARY KEY(child_id, parent_id) +) + +-- CREATE UNIQUE INDEX "index_channels_on_id_path" ON "channels" ("id_path"); + +CREATE TABLE "channel_members" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "admin" BOOLEAN NOT NULL DEFAULT false, + "updated_at" TIMESTAMP NOT NULL DEFAULT now +) + +CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql new file mode 100644 index 0000000000000000000000000000000000000000..a62eb0aaaf1f66a0ca8294aac2160a98896a1e79 --- /dev/null +++ b/crates/collab/migrations/20230727150500_add_channels.sql @@ -0,0 +1,19 @@ +CREATE TABLE "channels" ( + "id" SERIAL PRIMARY KEY, + "id_path" TEXT NOT NULL, + "name" VARCHAR NOT NULL, + "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now +) + +CREATE UNIQUE INDEX "index_channels_on_id_path" ON "channels" ("id_path"); + +CREATE TABLE "channel_members" ( + "id" SERIAL PRIMARY KEY, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "admin" BOOLEAN NOT NULL DEFAULT false, + "updated_at" TIMESTAMP NOT NULL DEFAULT now +) + +CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index e16fa9edb1efed6167556194acadbddd52763a07..ca7227917ca0e4efdc526b3d07cd918a8766cde4 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,4 +1,7 @@ mod access_token; +mod channel; +mod channel_member; +mod channel_parent; mod contact; mod follower; mod language_server; @@ -36,7 +39,7 @@ use sea_orm::{ DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement, TransactionTrait, }; -use sea_query::{Alias, Expr, OnConflict, Query}; +use sea_query::{Alias, Expr, OnConflict, Query, SelectStatement}; use serde::{Deserialize, Serialize}; pub use signup::{Invite, NewSignup, WaitlistSummary}; use sqlx::migrate::{Migrate, Migration, MigrationSource}; @@ -3027,6 +3030,138 @@ impl Database { .await } + // channels + + pub async fn get_channels(&self, user_id: UserId) -> Result> { + self.transaction(|tx| async move { + let tx = tx; + + let user = user::Model { + id: user_id, + ..Default::default() + }; + let mut channel_ids = user + .find_related(channel_member::Entity) + .select_only() + .column(channel_member::Column::ChannelId) + .all(&*tx) + .await; + + let descendants = Alias::new("descendants"); + let cte_referencing = SelectStatement::new() + .column(channel_parent::Column::ChildId) + .from(channel::Entity) + .and_where( + Expr::col(channel_parent::Column::ParentId) + .in_subquery(SelectStatement::new().from(descendants).take()) + ); + + /* + WITH RECURSIVE descendant_ids(id) AS ( + $1 + UNION ALL + SELECT child_id as id FROM channel_parents WHERE parent_id IN descendants + ) + SELECT * from channels where id in descendant_ids + */ + + + // WITH RECURSIVE descendants(id) AS ( + // // SQL QUERY FOR SELECTING Initial IDs + // UNION + // SELECT id FROM ancestors WHERE p.parent = id + // ) + // SELECT * FROM descendants; + + + + // let descendant_channel_ids = + + + + // let query = sea_query::Query::with().recursive(true); + + + for id_path in id_paths { + // + } + + + // zed/public/plugins + // zed/public/plugins/js + // zed/zed-livekit + // livekit/zed-livekit + // zed - 101 + // livekit - 500 + // zed-livekit - 510 + // public - 150 + // plugins - 200 + // js - 300 + // + // Channel, Parent - edges + // 510 - 500 + // 510 - 101 + // + // Given the channel 'Zed' (101) + // Select * from EDGES where parent = 101 => 510 + // + + + "SELECT * from channels where id_path like '$1?'" + + // https://www.postgresql.org/docs/current/queries-with.html + // https://www.sqlite.org/lang_with.html + + "SELECT channel_id from channel_ancestors where ancestor_id IN $()" + + // | channel_id | ancestor_ids | + // 150 150 + // 150 101 + // 200 101 + // 300 101 + // 200 150 + // 300 150 + // 300 200 + // + // // | channel_id | ancestor_ids | + // 150 101 + // 200 101 + // 300 101 + // 200 150 + // 300 [150, 200] + + channel::Entity::find() + .filter(channel::Column::IdPath.like(id_paths.unwrap())) + + dbg!(&id_paths.unwrap()[0].id_path); + + // let mut channel_members_by_channel_id = HashMap::new(); + // for channel_member in channel_members { + // channel_members_by_channel_id + // .entry(channel_member.channel_id) + // .or_insert_with(Vec::new) + // .push(channel_member); + // } + + // let mut channel_messages = channel_message::Entity::find() + // .filter(channel_message::Column::ChannelId.in_selection(channel_ids)) + // .all(&*tx) + // .await?; + + // let mut channel_messages_by_channel_id = HashMap::new(); + // for channel_message in channel_messages { + // channel_messages_by_channel_id + // .entry(channel_message.channel_id) + // .or_insert_with(Vec::new) + // .push(channel_message); + // } + + todo!(); + // Ok(channels) + }) + .await + } + async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, @@ -3400,6 +3535,8 @@ macro_rules! id_type { } id_type!(AccessTokenId); +id_type!(ChannelId); +id_type!(ChannelMemberId); id_type!(ContactId); id_type!(FollowerId); id_type!(RoomId); diff --git a/crates/collab/src/db/channel.rs b/crates/collab/src/db/channel.rs new file mode 100644 index 0000000000000000000000000000000000000000..ebf5c26ac87a3ccf9d380aa38a040a2ea6c78d9a --- /dev/null +++ b/crates/collab/src/db/channel.rs @@ -0,0 +1,39 @@ +use super::{ChannelId, RoomId}; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "channels")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: ChannelId, + pub room_id: Option, + // pub id_path: String, +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_one = "super::room::Entity")] + Room, + #[sea_orm(has_many = "super::channel_member::Entity")] + Member, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Member.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Room.def() + } +} + +// impl Related for Entity { +// fn to() -> RelationDef { +// Relation::Follower.def() +// } +// } diff --git a/crates/collab/src/db/channel_member.rs b/crates/collab/src/db/channel_member.rs new file mode 100644 index 0000000000000000000000000000000000000000..cad7f3853d403a9fe4311f090e7d2d1f57386ef7 --- /dev/null +++ b/crates/collab/src/db/channel_member.rs @@ -0,0 +1,59 @@ +use crate::db::channel_member; + +use super::{ChannelId, ChannelMemberId, UserId}; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "channel_members")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: ChannelMemberId, + pub channel_id: ChannelId, + pub user_id: UserId, +} + +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::channel::Entity", + from = "Column::ChannelId", + to = "super::channel::Column::Id" + )] + Channel, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Channel.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +#[derive(Debug)] +pub struct UserToChannel; + +impl Linked for UserToChannel { + type FromEntity = super::user::Entity; + + type ToEntity = super::channel::Entity; + + fn link(&self) -> Vec { + vec![ + channel_member::Relation::User.def().rev(), + channel_member::Relation::Channel.def(), + ] + } +} diff --git a/crates/collab/src/db/channel_parent.rs b/crates/collab/src/db/channel_parent.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf6cb447113e2afc591129020b47ab8cb903adf0 --- /dev/null +++ b/crates/collab/src/db/channel_parent.rs @@ -0,0 +1,13 @@ +use super::ChannelId; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "channel_parents")] +pub struct Model { + #[sea_orm(primary_key)] + pub child_id: ChannelId, + #[sea_orm(primary_key)] + pub parent_id: ChannelId, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/room.rs b/crates/collab/src/db/room.rs index c3e88670ebe00afa523b008e6bf669de01ec1d1e..c838d1273b5a41751a7dbd6226725f7067c7abff 100644 --- a/crates/collab/src/db/room.rs +++ b/crates/collab/src/db/room.rs @@ -37,4 +37,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Follower.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/user.rs b/crates/collab/src/db/user.rs index c2b157bd0a758880fd6fe64b079fa8760b59df5c..2d0e2fdf0b2e2c87be734fe9bbce4c38d1e80b01 100644 --- a/crates/collab/src/db/user.rs +++ b/crates/collab/src/db/user.rs @@ -26,6 +26,8 @@ pub enum Relation { RoomParticipant, #[sea_orm(has_many = "super::project::Entity")] HostedProjects, + #[sea_orm(has_many = "super::channel_member::Entity")] + ChannelMemberships, } impl Related for Entity { @@ -46,4 +48,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChannelMemberships.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bc79694d53eb04b86be14f3ae737e1a8bc6ef6ff..bdeac59af9cbfefe6204df44ce922d2bbf3a5f0e 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -17,10 +17,7 @@ use gpui::{ Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState, MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg, }, - geometry::{ - rect::RectF, - vector::vec2f, - }, + geometry::{rect::RectF, vector::vec2f}, platform::{CursorStyle, MouseButton, PromptLevel}, serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, @@ -1452,11 +1449,8 @@ impl View for CollabPanel { .with_child(ChildView::new(&self.context_menu, cx)) .into_any() }) - .on_click(MouseButton::Left, |_, v, cx| { - cx.focus_self() - }) + .on_click(MouseButton::Left, |_, _, cx| cx.focus_self()) .into_any_named("channels panel") - } } From 26a94b5244503b46539d4dd5ee632a289974388a Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 27 Jul 2023 10:47:45 -0700 Subject: [PATCH 012/105] WIP: Channel CRUD --- .../20221109000000_test_schema.sql | 6 +- crates/collab/src/db.rs | 265 ++++++++++-------- crates/collab/src/db/channel.rs | 1 + crates/collab/src/tests.rs | 1 + 4 files changed, 154 insertions(+), 119 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index a446f6b44025da5d3dda67cdf9c94ca5bb092697..ed7459e4a03ca1a9a3f38ee70b8d31b69fa8fc1d 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -191,13 +191,13 @@ CREATE TABLE "channels" ( "name" VARCHAR NOT NULL, "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now -) +); CREATE TABLE "channel_parents" ( "child_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "parent_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, PRIMARY KEY(child_id, parent_id) -) +); -- CREATE UNIQUE INDEX "index_channels_on_id_path" ON "channels" ("id_path"); @@ -207,6 +207,6 @@ CREATE TABLE "channel_members" ( "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "admin" BOOLEAN NOT NULL DEFAULT false, "updated_at" TIMESTAMP NOT NULL DEFAULT now -) +); CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index ca7227917ca0e4efdc526b3d07cd918a8766cde4..c8bec8a3f9dd61e9d4ade7c588158e28b81b8de5 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,7 +1,7 @@ mod access_token; mod channel; mod channel_member; -mod channel_parent; +// mod channel_parent; mod contact; mod follower; mod language_server; @@ -3032,134 +3032,167 @@ impl Database { // channels - pub async fn get_channels(&self, user_id: UserId) -> Result> { - self.transaction(|tx| async move { + pub async fn create_channel(&self, name: &str) -> Result { + self.transaction(move |tx| async move { let tx = tx; - let user = user::Model { - id: user_id, + let channel = channel::ActiveModel { + name: ActiveValue::Set(name.to_string()), ..Default::default() }; - let mut channel_ids = user - .find_related(channel_member::Entity) - .select_only() - .column(channel_member::Column::ChannelId) - .all(&*tx) - .await; - - let descendants = Alias::new("descendants"); - let cte_referencing = SelectStatement::new() - .column(channel_parent::Column::ChildId) - .from(channel::Entity) - .and_where( - Expr::col(channel_parent::Column::ParentId) - .in_subquery(SelectStatement::new().from(descendants).take()) - ); - - /* - WITH RECURSIVE descendant_ids(id) AS ( - $1 - UNION ALL - SELECT child_id as id FROM channel_parents WHERE parent_id IN descendants - ) - SELECT * from channels where id in descendant_ids - */ - - // WITH RECURSIVE descendants(id) AS ( - // // SQL QUERY FOR SELECTING Initial IDs - // UNION - // SELECT id FROM ancestors WHERE p.parent = id - // ) - // SELECT * FROM descendants; + let channel = channel.insert(&*tx).await?; + Ok(channel.id) + }).await + } + pub async fn add_channel_member(&self, channel_id: ChannelId, user_id: UserId) -> Result<()> { + self.transaction(move |tx| async move { + let tx = tx; - // let descendant_channel_ids = - - - - // let query = sea_query::Query::with().recursive(true); - - - for id_path in id_paths { - // - } - - - // zed/public/plugins - // zed/public/plugins/js - // zed/zed-livekit - // livekit/zed-livekit - // zed - 101 - // livekit - 500 - // zed-livekit - 510 - // public - 150 - // plugins - 200 - // js - 300 - // - // Channel, Parent - edges - // 510 - 500 - // 510 - 101 - // - // Given the channel 'Zed' (101) - // Select * from EDGES where parent = 101 => 510 - // - - - "SELECT * from channels where id_path like '$1?'" - - // https://www.postgresql.org/docs/current/queries-with.html - // https://www.sqlite.org/lang_with.html - - "SELECT channel_id from channel_ancestors where ancestor_id IN $()" - - // | channel_id | ancestor_ids | - // 150 150 - // 150 101 - // 200 101 - // 300 101 - // 200 150 - // 300 150 - // 300 200 - // - // // | channel_id | ancestor_ids | - // 150 101 - // 200 101 - // 300 101 - // 200 150 - // 300 [150, 200] - - channel::Entity::find() - .filter(channel::Column::IdPath.like(id_paths.unwrap())) - - dbg!(&id_paths.unwrap()[0].id_path); + let channel_membership = channel_member::ActiveModel { + channel_id: ActiveValue::Set(channel_id), + user_id: ActiveValue::Set(user_id), + ..Default::default() + }; - // let mut channel_members_by_channel_id = HashMap::new(); - // for channel_member in channel_members { - // channel_members_by_channel_id - // .entry(channel_member.channel_id) - // .or_insert_with(Vec::new) - // .push(channel_member); - // } + channel_membership.insert(&*tx).await?; - // let mut channel_messages = channel_message::Entity::find() - // .filter(channel_message::Column::ChannelId.in_selection(channel_ids)) - // .all(&*tx) - // .await?; + Ok(()) + }).await + } - // let mut channel_messages_by_channel_id = HashMap::new(); - // for channel_message in channel_messages { - // channel_messages_by_channel_id - // .entry(channel_message.channel_id) - // .or_insert_with(Vec::new) - // .push(channel_message); - // } + pub async fn get_channels(&self, user_id: UserId) -> Vec { + self.transaction(|tx| async move { + let tx = tx; - todo!(); - // Ok(channels) }) - .await + // let user = user::Model { + // id: user_id, + // ..Default::default() + // }; + // let mut channel_ids = user + // .find_related(channel_member::Entity) + // .select_only() + // .column(channel_member::Column::ChannelId) + // .all(&*tx) + // .await; + + // // let descendants = Alias::new("descendants"); + // // let cte_referencing = SelectStatement::new() + // // .column(channel_parent::Column::ChildId) + // // .from(channel::Entity) + // // .and_where( + // // Expr::col(channel_parent::Column::ParentId) + // // .in_subquery(SelectStatement::new().from(descendants).take()) + // // ); + + // // /* + // // WITH RECURSIVE descendant_ids(id) AS ( + // // $1 + // // UNION ALL + // // SELECT child_id as id FROM channel_parents WHERE parent_id IN descendants + // // ) + // // SELECT * from channels where id in descendant_ids + // // */ + + + // // // WITH RECURSIVE descendants(id) AS ( + // // // // SQL QUERY FOR SELECTING Initial IDs + // // // UNION + // // // SELECT id FROM ancestors WHERE p.parent = id + // // // ) + // // // SELECT * FROM descendants; + + + + // // // let descendant_channel_ids = + + + + // // // let query = sea_query::Query::with().recursive(true); + + + // // for id_path in id_paths { + // // // + // // } + + + // // // zed/public/plugins + // // // zed/public/plugins/js + // // // zed/zed-livekit + // // // livekit/zed-livekit + // // // zed - 101 + // // // livekit - 500 + // // // zed-livekit - 510 + // // // public - 150 + // // // plugins - 200 + // // // js - 300 + // // // + // // // Channel, Parent - edges + // // // 510 - 500 + // // // 510 - 101 + // // // + // // // Given the channel 'Zed' (101) + // // // Select * from EDGES where parent = 101 => 510 + // // // + + + // // "SELECT * from channels where id_path like '$1?'" + + // // // https://www.postgresql.org/docs/current/queries-with.html + // // // https://www.sqlite.org/lang_with.html + + // // "SELECT channel_id from channel_ancestors where ancestor_id IN $()" + + // // // | channel_id | ancestor_ids | + // // // 150 150 + // // // 150 101 + // // // 200 101 + // // // 300 101 + // // // 200 150 + // // // 300 150 + // // // 300 200 + // // // + // // // // | channel_id | ancestor_ids | + // // // 150 101 + // // // 200 101 + // // // 300 101 + // // // 200 150 + // // // 300 [150, 200] + + // // channel::Entity::find() + // // .filter(channel::Column::IdPath.like(id_paths.unwrap())) + + // // dbg!(&id_paths.unwrap()[0].id_path); + + // // // let mut channel_members_by_channel_id = HashMap::new(); + // // // for channel_member in channel_members { + // // // channel_members_by_channel_id + // // // .entry(channel_member.channel_id) + // // // .or_insert_with(Vec::new) + // // // .push(channel_member); + // // // } + + // // // let mut channel_messages = channel_message::Entity::find() + // // // .filter(channel_message::Column::ChannelId.in_selection(channel_ids)) + // // // .all(&*tx) + // // // .await?; + + // // // let mut channel_messages_by_channel_id = HashMap::new(); + // // // for channel_message in channel_messages { + // // // channel_messages_by_channel_id + // // // .entry(channel_message.channel_id) + // // // .or_insert_with(Vec::new) + // // // .push(channel_message); + // // // } + + // // todo!(); + // // // Ok(channels) + // Err(Error("not implemented")) + // }) + // .await } async fn transaction(&self, f: F) -> Result diff --git a/crates/collab/src/db/channel.rs b/crates/collab/src/db/channel.rs index ebf5c26ac87a3ccf9d380aa38a040a2ea6c78d9a..f8e2c3b85b944bf70f67fc32a475e878237ca19d 100644 --- a/crates/collab/src/db/channel.rs +++ b/crates/collab/src/db/channel.rs @@ -6,6 +6,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: ChannelId, + pub name: String, pub room_id: Option, // pub id_path: String, } diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index b1d0bedb2cf23c856f320aacabb8d2fb489f2e2a..2e98cd9b4d2eccdbf61dc005f87c8c27ce76d9d2 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -35,6 +35,7 @@ use workspace::Workspace; mod integration_tests; mod randomized_integration_tests; +mod channel_tests; struct TestServer { app_state: Arc, From 15631a6fd51ea5647bcc847ed51223cc9fdffd9c Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 27 Jul 2023 21:31:01 -0700 Subject: [PATCH 013/105] Add channel_tests.rs --- crates/collab/src/tests/channel_tests.rs | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 crates/collab/src/tests/channel_tests.rs diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..24754adeb3adacf40ae4ef657ef460d34274c13c --- /dev/null +++ b/crates/collab/src/tests/channel_tests.rs @@ -0,0 +1,29 @@ +use gpui::{executor::Deterministic, TestAppContext}; +use std::sync::Arc; + +use super::TestServer; + +#[gpui::test] +async fn test_basic_channels(deterministic: Arc, cx: &mut TestAppContext) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx, "user_a").await; + let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); + let db = server._test_db.db(); + + let zed_id = db.create_channel("zed").await.unwrap(); + + db.add_channel_member(zed_id, a_id).await.unwrap(); + + let channels = db.get_channels(a_id).await; + + assert_eq!(channels, vec![zed_id]); +} + +/* +Linear things: +- A way of expressing progress to the team +- A way for us to agree on a scope +- A way to figure out what we're supposed to be doing + +*/ From 0998440bddf09a8425a6890dc8bc89052d62feb9 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 28 Jul 2023 13:14:24 -0700 Subject: [PATCH 014/105] implement recursive channel query --- .../20221109000000_test_schema.sql | 3 - crates/collab/src/db.rs | 256 +++++++++--------- crates/collab/src/db/channel.rs | 1 - crates/collab/src/db/channel_parent.rs | 3 + crates/collab/src/tests/channel_tests.rs | 66 ++++- 5 files changed, 191 insertions(+), 138 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index ed7459e4a03ca1a9a3f38ee70b8d31b69fa8fc1d..b397438e27925f4c7f24bf69e887e173e00bb3d9 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -187,7 +187,6 @@ CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); CREATE TABLE "channels" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - -- "id_path" TEXT NOT NULL, "name" VARCHAR NOT NULL, "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now @@ -199,8 +198,6 @@ CREATE TABLE "channel_parents" ( PRIMARY KEY(child_id, parent_id) ); --- CREATE UNIQUE INDEX "index_channels_on_id_path" ON "channels" ("id_path"); - CREATE TABLE "channel_members" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index c8bec8a3f9dd61e9d4ade7c588158e28b81b8de5..5755ed73e2d07b40e274a67fe04239a3dd55bf69 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,7 +1,7 @@ mod access_token; mod channel; mod channel_member; -// mod channel_parent; +mod channel_parent; mod contact; mod follower; mod language_server; @@ -39,7 +39,10 @@ use sea_orm::{ DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement, TransactionTrait, }; -use sea_query::{Alias, Expr, OnConflict, Query, SelectStatement}; +use sea_query::{ + Alias, ColumnRef, CommonTableExpression, Expr, OnConflict, Order, Query, QueryStatementWriter, + SelectStatement, UnionType, WithClause, +}; use serde::{Deserialize, Serialize}; pub use signup::{Invite, NewSignup, WaitlistSummary}; use sqlx::migrate::{Migrate, Migration, MigrationSource}; @@ -3032,7 +3035,11 @@ impl Database { // channels - pub async fn create_channel(&self, name: &str) -> Result { + pub async fn create_root_channel(&self, name: &str) -> Result { + self.create_channel(name, None).await + } + + pub async fn create_channel(&self, name: &str, parent: Option) -> Result { self.transaction(move |tx| async move { let tx = tx; @@ -3043,10 +3050,21 @@ impl Database { let channel = channel.insert(&*tx).await?; + if let Some(parent) = parent { + channel_parent::ActiveModel { + child_id: ActiveValue::Set(channel.id), + parent_id: ActiveValue::Set(parent), + } + .insert(&*tx) + .await?; + } + Ok(channel.id) - }).await + }) + .await } + // Property: Members are only pub async fn add_channel_member(&self, channel_id: ChannelId, user_id: UserId) -> Result<()> { self.transaction(move |tx| async move { let tx = tx; @@ -3060,139 +3078,108 @@ impl Database { channel_membership.insert(&*tx).await?; Ok(()) - }).await + }) + .await } - pub async fn get_channels(&self, user_id: UserId) -> Vec { + pub async fn get_channels(&self, user_id: UserId) -> Result> { self.transaction(|tx| async move { let tx = tx; + // This is the SQL statement we want to generate: + let sql = r#" + WITH RECURSIVE channel_tree(child_id, parent_id, depth) AS ( + SELECT channel_id as child_id, NULL as parent_id, 0 + FROM channel_members + WHERE user_id = ? + UNION ALL + SELECT channel_parents.child_id, channel_parents.parent_id, channel_tree.depth + 1 + FROM channel_parents, channel_tree + WHERE channel_parents.parent_id = channel_tree.child_id + ) + SELECT channel_tree.child_id as id, channels.name, channel_tree.parent_id + FROM channel_tree + JOIN channels ON channels.id = channel_tree.child_id + ORDER BY channel_tree.depth; + "#; + + // let root_channel_ids_query = SelectStatement::new() + // .column(channel_member::Column::ChannelId) + // .expr(Expr::value("NULL")) + // .from(channel_member::Entity.table_ref()) + // .and_where( + // Expr::col(channel_member::Column::UserId) + // .eq(Expr::cust_with_values("?", vec![user_id])), + // ); + + // let build_tree_query = SelectStatement::new() + // .column(channel_parent::Column::ChildId) + // .column(channel_parent::Column::ParentId) + // .expr(Expr::col(Alias::new("channel_tree.depth")).add(1i32)) + // .from(Alias::new("channel_tree")) + // .and_where( + // Expr::col(channel_parent::Column::ParentId) + // .equals(Alias::new("channel_tree"), Alias::new("child_id")), + // ) + // .to_owned(); + + // let common_table_expression = CommonTableExpression::new() + // .query( + // root_channel_ids_query + // .union(UnionType::Distinct, build_tree_query) + // .to_owned(), + // ) + // .column(Alias::new("child_id")) + // .column(Alias::new("parent_id")) + // .column(Alias::new("depth")) + // .table_name(Alias::new("channel_tree")) + // .to_owned(); + + // let select = SelectStatement::new() + // .expr_as( + // Expr::col(Alias::new("channel_tree.child_id")), + // Alias::new("id"), + // ) + // .column(channel::Column::Name) + // .column(Alias::new("channel_tree.parent_id")) + // .from(Alias::new("channel_tree")) + // .inner_join( + // channel::Entity.table_ref(), + // Expr::eq( + // channel::Column::Id.into_expr(), + // Expr::tbl(Alias::new("channel_tree"), Alias::new("child_id")), + // ), + // ) + // .order_by(Alias::new("channel_tree.child_id"), Order::Asc) + // .to_owned(); + + // let with_clause = WithClause::new() + // .recursive(true) + // .cte(common_table_expression) + // .to_owned(); + + // let query = select.with(with_clause); + + // let query = SelectStatement::new() + // .column(ColumnRef::Asterisk) + // .from_subquery(query, Alias::new("channel_tree") + // .to_owned(); + + // let stmt = self.pool.get_database_backend().build(&query); + + let stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + vec![user_id.into()], + ); + + Ok(channel_parent::Entity::find() + .from_raw_sql(stmt) + .into_model::() + .all(&*tx) + .await?) }) - // let user = user::Model { - // id: user_id, - // ..Default::default() - // }; - // let mut channel_ids = user - // .find_related(channel_member::Entity) - // .select_only() - // .column(channel_member::Column::ChannelId) - // .all(&*tx) - // .await; - - // // let descendants = Alias::new("descendants"); - // // let cte_referencing = SelectStatement::new() - // // .column(channel_parent::Column::ChildId) - // // .from(channel::Entity) - // // .and_where( - // // Expr::col(channel_parent::Column::ParentId) - // // .in_subquery(SelectStatement::new().from(descendants).take()) - // // ); - - // // /* - // // WITH RECURSIVE descendant_ids(id) AS ( - // // $1 - // // UNION ALL - // // SELECT child_id as id FROM channel_parents WHERE parent_id IN descendants - // // ) - // // SELECT * from channels where id in descendant_ids - // // */ - - - // // // WITH RECURSIVE descendants(id) AS ( - // // // // SQL QUERY FOR SELECTING Initial IDs - // // // UNION - // // // SELECT id FROM ancestors WHERE p.parent = id - // // // ) - // // // SELECT * FROM descendants; - - - - // // // let descendant_channel_ids = - - - - // // // let query = sea_query::Query::with().recursive(true); - - - // // for id_path in id_paths { - // // // - // // } - - - // // // zed/public/plugins - // // // zed/public/plugins/js - // // // zed/zed-livekit - // // // livekit/zed-livekit - // // // zed - 101 - // // // livekit - 500 - // // // zed-livekit - 510 - // // // public - 150 - // // // plugins - 200 - // // // js - 300 - // // // - // // // Channel, Parent - edges - // // // 510 - 500 - // // // 510 - 101 - // // // - // // // Given the channel 'Zed' (101) - // // // Select * from EDGES where parent = 101 => 510 - // // // - - - // // "SELECT * from channels where id_path like '$1?'" - - // // // https://www.postgresql.org/docs/current/queries-with.html - // // // https://www.sqlite.org/lang_with.html - - // // "SELECT channel_id from channel_ancestors where ancestor_id IN $()" - - // // // | channel_id | ancestor_ids | - // // // 150 150 - // // // 150 101 - // // // 200 101 - // // // 300 101 - // // // 200 150 - // // // 300 150 - // // // 300 200 - // // // - // // // // | channel_id | ancestor_ids | - // // // 150 101 - // // // 200 101 - // // // 300 101 - // // // 200 150 - // // // 300 [150, 200] - - // // channel::Entity::find() - // // .filter(channel::Column::IdPath.like(id_paths.unwrap())) - - // // dbg!(&id_paths.unwrap()[0].id_path); - - // // // let mut channel_members_by_channel_id = HashMap::new(); - // // // for channel_member in channel_members { - // // // channel_members_by_channel_id - // // // .entry(channel_member.channel_id) - // // // .or_insert_with(Vec::new) - // // // .push(channel_member); - // // // } - - // // // let mut channel_messages = channel_message::Entity::find() - // // // .filter(channel_message::Column::ChannelId.in_selection(channel_ids)) - // // // .all(&*tx) - // // // .await?; - - // // // let mut channel_messages_by_channel_id = HashMap::new(); - // // // for channel_message in channel_messages { - // // // channel_messages_by_channel_id - // // // .entry(channel_message.channel_id) - // // // .or_insert_with(Vec::new) - // // // .push(channel_message); - // // // } - - // // todo!(); - // // // Ok(channels) - // Err(Error("not implemented")) - // }) - // .await + .await } async fn transaction(&self, f: F) -> Result @@ -3440,6 +3427,13 @@ pub struct NewUserResult { pub signup_device_id: Option, } +#[derive(FromQueryResult, Debug, PartialEq)] +pub struct Channel { + pub id: ChannelId, + pub name: String, + pub parent_id: Option, +} + fn random_invite_code() -> String { nanoid::nanoid!(16) } diff --git a/crates/collab/src/db/channel.rs b/crates/collab/src/db/channel.rs index f8e2c3b85b944bf70f67fc32a475e878237ca19d..48e5d50e3ea2d2f59a169a4500a466aa183d0d41 100644 --- a/crates/collab/src/db/channel.rs +++ b/crates/collab/src/db/channel.rs @@ -8,7 +8,6 @@ pub struct Model { pub id: ChannelId, pub name: String, pub room_id: Option, - // pub id_path: String, } impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/channel_parent.rs b/crates/collab/src/db/channel_parent.rs index bf6cb447113e2afc591129020b47ab8cb903adf0..b0072155a3ce666f26124cc0372657d7c9512d85 100644 --- a/crates/collab/src/db/channel_parent.rs +++ b/crates/collab/src/db/channel_parent.rs @@ -11,3 +11,6 @@ pub struct Model { } impl ActiveModelBehavior for ActiveModel {} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 24754adeb3adacf40ae4ef657ef460d34274c13c..8ab33adcbf60490272d2540731c04ae5c008a6a0 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,6 +1,8 @@ use gpui::{executor::Deterministic, TestAppContext}; use std::sync::Arc; +use crate::db::Channel; + use super::TestServer; #[gpui::test] @@ -11,13 +13,71 @@ async fn test_basic_channels(deterministic: Arc, cx: &mut TestApp let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); let db = server._test_db.db(); - let zed_id = db.create_channel("zed").await.unwrap(); + let zed_id = db.create_root_channel("zed").await.unwrap(); + let crdb_id = db.create_channel("crdb", Some(zed_id)).await.unwrap(); + let livestreaming_id = db + .create_channel("livestreaming", Some(zed_id)) + .await + .unwrap(); + let replace_id = db.create_channel("replace", Some(zed_id)).await.unwrap(); + let rust_id = db.create_root_channel("rust").await.unwrap(); + let cargo_id = db.create_channel("cargo", Some(rust_id)).await.unwrap(); db.add_channel_member(zed_id, a_id).await.unwrap(); + db.add_channel_member(rust_id, a_id).await.unwrap(); + + let channels = db.get_channels(a_id).await.unwrap(); + assert_eq!( + channels, + vec![ + Channel { + id: zed_id, + name: "zed".to_string(), + parent_id: None, + }, + Channel { + id: rust_id, + name: "rust".to_string(), + parent_id: None, + }, + Channel { + id: crdb_id, + name: "crdb".to_string(), + parent_id: Some(zed_id), + }, + Channel { + id: livestreaming_id, + name: "livestreaming".to_string(), + parent_id: Some(zed_id), + }, + Channel { + id: replace_id, + name: "replace".to_string(), + parent_id: Some(zed_id), + }, + Channel { + id: cargo_id, + name: "cargo".to_string(), + parent_id: Some(rust_id), + } + ] + ); +} - let channels = db.get_channels(a_id).await; +#[gpui::test] +async fn test_block_cycle_creation(deterministic: Arc, cx: &mut TestAppContext) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx, "user_a").await; + let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); + let db = server._test_db.db(); - assert_eq!(channels, vec![zed_id]); + let zed_id = db.create_root_channel("zed").await.unwrap(); + let first_id = db.create_channel("first", Some(zed_id)).await.unwrap(); + let second_id = db + .create_channel("second_id", Some(first_id)) + .await + .unwrap(); } /* From 758e1f6e5752b1e3fc994d885865a24da1afb45c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 28 Jul 2023 14:56:02 -0700 Subject: [PATCH 015/105] Get DB channels query working with postgres Co-authored-by: Mikayla --- .../20230727150500_add_channels.sql | 21 +++-- crates/collab/src/db.rs | 76 +------------------ crates/collab/src/db/tests.rs | 67 ++++++++++++++++ 3 files changed, 86 insertions(+), 78 deletions(-) diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql index a62eb0aaaf1f66a0ca8294aac2160a98896a1e79..0073d29c681f838436349b999f6704620c227aab 100644 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ b/crates/collab/migrations/20230727150500_add_channels.sql @@ -1,19 +1,28 @@ +DROP TABLE "channel_messages"; +DROP TABLE "channel_memberships"; +DROP TABLE "org_memberships"; +DROP TABLE "orgs"; +DROP TABLE "channels"; + CREATE TABLE "channels" ( "id" SERIAL PRIMARY KEY, - "id_path" TEXT NOT NULL, "name" VARCHAR NOT NULL, "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT now -) + "created_at" TIMESTAMP NOT NULL DEFAULT now() +); -CREATE UNIQUE INDEX "index_channels_on_id_path" ON "channels" ("id_path"); +CREATE TABLE "channel_parents" ( + "child_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "parent_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + PRIMARY KEY(child_id, parent_id) +); CREATE TABLE "channel_members" ( "id" SERIAL PRIMARY KEY, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "admin" BOOLEAN NOT NULL DEFAULT false, - "updated_at" TIMESTAMP NOT NULL DEFAULT now -) + "updated_at" TIMESTAMP NOT NULL DEFAULT now() +); CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 5755ed73e2d07b40e274a67fe04239a3dd55bf69..d3336824e6383d909e3a334cfc8931c10c353e36 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -39,10 +39,7 @@ use sea_orm::{ DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement, TransactionTrait, }; -use sea_query::{ - Alias, ColumnRef, CommonTableExpression, Expr, OnConflict, Order, Query, QueryStatementWriter, - SelectStatement, UnionType, WithClause, -}; +use sea_query::{Alias, Expr, OnConflict, Query}; use serde::{Deserialize, Serialize}; pub use signup::{Invite, NewSignup, WaitlistSummary}; use sqlx::migrate::{Migrate, Migration, MigrationSource}; @@ -3086,13 +3083,12 @@ impl Database { self.transaction(|tx| async move { let tx = tx; - // This is the SQL statement we want to generate: let sql = r#" WITH RECURSIVE channel_tree(child_id, parent_id, depth) AS ( - SELECT channel_id as child_id, NULL as parent_id, 0 + SELECT channel_id as child_id, CAST(NULL as INTEGER) as parent_id, 0 FROM channel_members - WHERE user_id = ? - UNION ALL + WHERE user_id = $1 + UNION SELECT channel_parents.child_id, channel_parents.parent_id, channel_tree.depth + 1 FROM channel_parents, channel_tree WHERE channel_parents.parent_id = channel_tree.child_id @@ -3103,70 +3099,6 @@ impl Database { ORDER BY channel_tree.depth; "#; - // let root_channel_ids_query = SelectStatement::new() - // .column(channel_member::Column::ChannelId) - // .expr(Expr::value("NULL")) - // .from(channel_member::Entity.table_ref()) - // .and_where( - // Expr::col(channel_member::Column::UserId) - // .eq(Expr::cust_with_values("?", vec![user_id])), - // ); - - // let build_tree_query = SelectStatement::new() - // .column(channel_parent::Column::ChildId) - // .column(channel_parent::Column::ParentId) - // .expr(Expr::col(Alias::new("channel_tree.depth")).add(1i32)) - // .from(Alias::new("channel_tree")) - // .and_where( - // Expr::col(channel_parent::Column::ParentId) - // .equals(Alias::new("channel_tree"), Alias::new("child_id")), - // ) - // .to_owned(); - - // let common_table_expression = CommonTableExpression::new() - // .query( - // root_channel_ids_query - // .union(UnionType::Distinct, build_tree_query) - // .to_owned(), - // ) - // .column(Alias::new("child_id")) - // .column(Alias::new("parent_id")) - // .column(Alias::new("depth")) - // .table_name(Alias::new("channel_tree")) - // .to_owned(); - - // let select = SelectStatement::new() - // .expr_as( - // Expr::col(Alias::new("channel_tree.child_id")), - // Alias::new("id"), - // ) - // .column(channel::Column::Name) - // .column(Alias::new("channel_tree.parent_id")) - // .from(Alias::new("channel_tree")) - // .inner_join( - // channel::Entity.table_ref(), - // Expr::eq( - // channel::Column::Id.into_expr(), - // Expr::tbl(Alias::new("channel_tree"), Alias::new("child_id")), - // ), - // ) - // .order_by(Alias::new("channel_tree.child_id"), Order::Asc) - // .to_owned(); - - // let with_clause = WithClause::new() - // .recursive(true) - // .cte(common_table_expression) - // .to_owned(); - - // let query = select.with(with_clause); - - // let query = SelectStatement::new() - // .column(ColumnRef::Asterisk) - // .from_subquery(query, Alias::new("channel_tree") - // .to_owned(); - - // let stmt = self.pool.get_database_backend().build(&query); - let stmt = Statement::from_sql_and_values( self.pool.get_database_backend(), sql, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 855dfec91feaf627085019dbb62a5cd247b9803a..53c35ef31bfdd2b1b447790e291090ad49240988 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -879,6 +879,73 @@ async fn test_invite_codes() { assert!(db.has_contact(user5, user1).await.unwrap()); } +test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { + let a_id = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "user1".into(), + github_user_id: 5, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let zed_id = db.create_root_channel("zed").await.unwrap(); + let crdb_id = db.create_channel("crdb", Some(zed_id)).await.unwrap(); + let livestreaming_id = db + .create_channel("livestreaming", Some(zed_id)) + .await + .unwrap(); + let replace_id = db.create_channel("replace", Some(zed_id)).await.unwrap(); + let rust_id = db.create_root_channel("rust").await.unwrap(); + let cargo_id = db.create_channel("cargo", Some(rust_id)).await.unwrap(); + + db.add_channel_member(zed_id, a_id).await.unwrap(); + db.add_channel_member(rust_id, a_id).await.unwrap(); + + let channels = db.get_channels(a_id).await.unwrap(); + + assert_eq!( + channels, + vec![ + Channel { + id: zed_id, + name: "zed".to_string(), + parent_id: None, + }, + Channel { + id: rust_id, + name: "rust".to_string(), + parent_id: None, + }, + Channel { + id: crdb_id, + name: "crdb".to_string(), + parent_id: Some(zed_id), + }, + Channel { + id: livestreaming_id, + name: "livestreaming".to_string(), + parent_id: Some(zed_id), + }, + Channel { + id: replace_id, + name: "replace".to_string(), + parent_id: Some(zed_id), + }, + Channel { + id: cargo_id, + name: "cargo".to_string(), + parent_id: Some(rust_id), + } + ] + ); +}); + #[gpui::test] async fn test_multiple_signup_overwrite() { let test_db = TestDb::postgres(build_background_executor()); From 4b94bfa04522273d605f1a208fc64db8fa20fb19 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 28 Jul 2023 17:05:56 -0700 Subject: [PATCH 016/105] Set up basic RPC for managing channels Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 189 ++++++++++++++++++ crates/client/src/client.rs | 2 + .../20221109000000_test_schema.sql | 1 + .../20230727150500_add_channels.sql | 1 + crates/collab/src/db.rs | 110 +++++++++- crates/collab/src/db/channel_member.rs | 2 + crates/collab/src/db/tests.rs | 21 +- crates/collab/src/rpc.rs | 100 ++++++++- crates/collab/src/tests.rs | 9 +- crates/collab/src/tests/channel_tests.rs | 149 ++++++++------ crates/rpc/proto/zed.proto | 81 +++----- crates/rpc/src/proto.rs | 26 +-- 12 files changed, 541 insertions(+), 150 deletions(-) create mode 100644 crates/client/src/channel_store.rs diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..a72a189415bb2982126ee0c0fc3451ab9e2022d5 --- /dev/null +++ b/crates/client/src/channel_store.rs @@ -0,0 +1,189 @@ +use crate::{Client, Subscription, User, UserStore}; +use anyhow::Result; +use futures::Future; +use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; +use rpc::{proto, TypedEnvelope}; +use std::sync::Arc; + +pub struct ChannelStore { + channels: Vec, + channel_invitations: Vec, + client: Arc, + user_store: ModelHandle, + rpc_subscription: Subscription, +} + +#[derive(Debug, PartialEq)] +pub struct Channel { + pub id: u64, + pub name: String, + pub parent_id: Option, +} + +impl Entity for ChannelStore { + type Event = (); +} + +impl ChannelStore { + pub fn new( + client: Arc, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> Self { + let rpc_subscription = + client.add_message_handler(cx.handle(), Self::handle_update_channels); + Self { + channels: vec![], + channel_invitations: vec![], + client, + user_store, + rpc_subscription, + } + } + + pub fn channels(&self) -> &[Channel] { + &self.channels + } + + pub fn channel_invitations(&self) -> &[Channel] { + &self.channel_invitations + } + + pub fn create_channel( + &self, + name: &str, + parent_id: Option, + ) -> impl Future> { + let client = self.client.clone(); + let name = name.to_owned(); + async move { + Ok(client + .request(proto::CreateChannel { name, parent_id }) + .await? + .channel_id) + } + } + + pub fn invite_member( + &self, + channel_id: u64, + user_id: u64, + admin: bool, + ) -> impl Future> { + let client = self.client.clone(); + async move { + client + .request(proto::InviteChannelMember { + channel_id, + user_id, + admin, + }) + .await?; + Ok(()) + } + } + + pub fn respond_to_channel_invite( + &mut self, + channel_id: u64, + accept: bool, + ) -> impl Future> { + let client = self.client.clone(); + async move { + client + .request(proto::RespondToChannelInvite { channel_id, accept }) + .await?; + Ok(()) + } + } + + pub fn remove_member( + &self, + channel_id: u64, + user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + todo!() + } + + pub fn channel_members( + &self, + channel_id: u64, + cx: &mut ModelContext, + ) -> Task>>> { + todo!() + } + + pub fn add_guest_channel(&self, channel_id: u64) -> Task> { + todo!() + } + + async fn handle_update_channels( + this: ModelHandle, + message: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let payload = message.payload; + this.update(&mut cx, |this, cx| { + this.channels + .retain(|channel| !payload.remove_channels.contains(&channel.id)); + this.channel_invitations + .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); + + for channel in payload.channel_invitations { + if let Some(existing_channel) = this + .channel_invitations + .iter_mut() + .find(|c| c.id == channel.id) + { + existing_channel.name = channel.name; + continue; + } + + this.channel_invitations.insert( + 0, + Channel { + id: channel.id, + name: channel.name, + parent_id: None, + }, + ); + } + + for channel in payload.channels { + if let Some(existing_channel) = + this.channels.iter_mut().find(|c| c.id == channel.id) + { + existing_channel.name = channel.name; + continue; + } + + if let Some(parent_id) = channel.parent_id { + if let Some(ix) = this.channels.iter().position(|c| c.id == parent_id) { + this.channels.insert( + ix + 1, + Channel { + id: channel.id, + name: channel.name, + parent_id: Some(parent_id), + }, + ); + } + } else { + this.channels.insert( + 0, + Channel { + id: channel.id, + name: channel.name, + parent_id: None, + }, + ); + } + } + cx.notify(); + }); + + Ok(()) + } +} diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 78bcc55e93ffe57dcec4ce760978ab2d22a321e8..af33c738ce4bb7729768e5f2cf10a338abe3ab10 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,6 +1,7 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; +pub mod channel_store; pub mod telemetry; pub mod user; @@ -44,6 +45,7 @@ use util::channel::ReleaseChannel; use util::http::HttpClient; use util::{ResultExt, TryFutureExt}; +pub use channel_store::*; pub use rpc::*; pub use telemetry::ClickhouseEvent; pub use user::*; diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index b397438e27925f4c7f24bf69e887e173e00bb3d9..1ead36fde20eddbb689a32c72a77e2bf0ca7870a 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -203,6 +203,7 @@ CREATE TABLE "channel_members" ( "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "admin" BOOLEAN NOT NULL DEFAULT false, + "accepted" BOOLEAN NOT NULL DEFAULT false, "updated_at" TIMESTAMP NOT NULL DEFAULT now ); diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql index 0073d29c681f838436349b999f6704620c227aab..05886777929101b97db522af1be82fd17fe9bad7 100644 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ b/crates/collab/migrations/20230727150500_add_channels.sql @@ -22,6 +22,7 @@ CREATE TABLE "channel_members" ( "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "admin" BOOLEAN NOT NULL DEFAULT false, + "accepted" BOOLEAN NOT NULL DEFAULT false, "updated_at" TIMESTAMP NOT NULL DEFAULT now() ); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index d3336824e6383d909e3a334cfc8931c10c353e36..46fca04658c90bc225d714c9f842e798a2aed294 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3032,11 +3032,16 @@ impl Database { // channels - pub async fn create_root_channel(&self, name: &str) -> Result { - self.create_channel(name, None).await + pub async fn create_root_channel(&self, name: &str, creator_id: UserId) -> Result { + self.create_channel(name, None, creator_id).await } - pub async fn create_channel(&self, name: &str, parent: Option) -> Result { + pub async fn create_channel( + &self, + name: &str, + parent: Option, + creator_id: UserId, + ) -> Result { self.transaction(move |tx| async move { let tx = tx; @@ -3056,19 +3061,50 @@ impl Database { .await?; } + channel_member::ActiveModel { + channel_id: ActiveValue::Set(channel.id), + user_id: ActiveValue::Set(creator_id), + accepted: ActiveValue::Set(true), + admin: ActiveValue::Set(true), + ..Default::default() + } + .insert(&*tx) + .await?; + Ok(channel.id) }) .await } - // Property: Members are only - pub async fn add_channel_member(&self, channel_id: ChannelId, user_id: UserId) -> Result<()> { + pub async fn invite_channel_member( + &self, + channel_id: ChannelId, + invitee_id: UserId, + inviter_id: UserId, + is_admin: bool, + ) -> Result<()> { self.transaction(move |tx| async move { let tx = tx; + // Check if inviter is a member + channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(inviter_id)) + .and(channel_member::Column::Admin.eq(true)), + ) + .one(&*tx) + .await? + .ok_or_else(|| { + anyhow!("Inviter does not have permissions to invite the invitee") + })?; + let channel_membership = channel_member::ActiveModel { channel_id: ActiveValue::Set(channel_id), - user_id: ActiveValue::Set(user_id), + user_id: ActiveValue::Set(invitee_id), + accepted: ActiveValue::Set(false), + admin: ActiveValue::Set(is_admin), ..Default::default() }; @@ -3079,6 +3115,50 @@ impl Database { .await } + pub async fn respond_to_channel_invite( + &self, + channel_id: ChannelId, + user_id: UserId, + accept: bool, + ) -> Result<()> { + self.transaction(move |tx| async move { + let tx = tx; + + let rows_affected = if accept { + channel_member::Entity::update_many() + .set(channel_member::ActiveModel { + accepted: ActiveValue::Set(accept), + ..Default::default() + }) + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(user_id)) + .and(channel_member::Column::Accepted.eq(false)), + ) + .exec(&*tx) + .await? + .rows_affected + } else { + channel_member::ActiveModel { + channel_id: ActiveValue::Unchanged(channel_id), + user_id: ActiveValue::Unchanged(user_id), + ..Default::default() + } + .delete(&*tx) + .await? + .rows_affected + }; + + if rows_affected == 0 { + Err(anyhow!("no such invitation"))?; + } + + Ok(()) + }) + .await + } + pub async fn get_channels(&self, user_id: UserId) -> Result> { self.transaction(|tx| async move { let tx = tx; @@ -3087,7 +3167,7 @@ impl Database { WITH RECURSIVE channel_tree(child_id, parent_id, depth) AS ( SELECT channel_id as child_id, CAST(NULL as INTEGER) as parent_id, 0 FROM channel_members - WHERE user_id = $1 + WHERE user_id = $1 AND accepted UNION SELECT channel_parents.child_id, channel_parents.parent_id, channel_tree.depth + 1 FROM channel_parents, channel_tree @@ -3114,6 +3194,22 @@ impl Database { .await } + pub async fn get_channel(&self, channel_id: ChannelId) -> Result { + self.transaction(|tx| async move { + let tx = tx; + let channel = channel::Entity::find_by_id(channel_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such channel"))?; + Ok(Channel { + id: channel.id, + name: channel.name, + parent_id: None, + }) + }) + .await + } + async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, diff --git a/crates/collab/src/db/channel_member.rs b/crates/collab/src/db/channel_member.rs index cad7f3853d403a9fe4311f090e7d2d1f57386ef7..f0f1a852cb6a797359ef516f72f76b0a2ec627b5 100644 --- a/crates/collab/src/db/channel_member.rs +++ b/crates/collab/src/db/channel_member.rs @@ -10,6 +10,8 @@ pub struct Model { pub id: ChannelMemberId, pub channel_id: ChannelId, pub user_id: UserId, + pub accepted: bool, + pub admin: bool, } impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 53c35ef31bfdd2b1b447790e291090ad49240988..03e9eb577b930f74f48f8793f76008b3aabcec16 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -894,18 +894,21 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .unwrap() .user_id; - let zed_id = db.create_root_channel("zed").await.unwrap(); - let crdb_id = db.create_channel("crdb", Some(zed_id)).await.unwrap(); + let zed_id = db.create_root_channel("zed", a_id).await.unwrap(); + let crdb_id = db.create_channel("crdb", Some(zed_id), a_id).await.unwrap(); let livestreaming_id = db - .create_channel("livestreaming", Some(zed_id)) + .create_channel("livestreaming", Some(zed_id), a_id) + .await + .unwrap(); + let replace_id = db + .create_channel("replace", Some(zed_id), a_id) + .await + .unwrap(); + let rust_id = db.create_root_channel("rust", a_id).await.unwrap(); + let cargo_id = db + .create_channel("cargo", Some(rust_id), a_id) .await .unwrap(); - let replace_id = db.create_channel("replace", Some(zed_id)).await.unwrap(); - let rust_id = db.create_root_channel("rust").await.unwrap(); - let cargo_id = db.create_channel("cargo", Some(rust_id)).await.unwrap(); - - db.add_channel_member(zed_id, a_id).await.unwrap(); - db.add_channel_member(rust_id, a_id).await.unwrap(); let channels = db.get_channels(a_id).await.unwrap(); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 14d785307d317ee03136f8cfbc728c73237d0451..3d95d484eed246ca5ea45c47be3f78db7c1c74d5 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2,7 +2,7 @@ mod connection_pool; use crate::{ auth, - db::{self, Database, ProjectId, RoomId, ServerId, User, UserId}, + db::{self, ChannelId, Database, ProjectId, RoomId, ServerId, User, UserId}, executor::Executor, AppState, Result, }; @@ -239,6 +239,10 @@ impl Server { .add_request_handler(request_contact) .add_request_handler(remove_contact) .add_request_handler(respond_to_contact_request) + .add_request_handler(create_channel) + .add_request_handler(invite_channel_member) + .add_request_handler(remove_channel_member) + .add_request_handler(respond_to_channel_invite) .add_request_handler(follow) .add_message_handler(unfollow) .add_message_handler(update_followers) @@ -2084,6 +2088,100 @@ async fn remove_contact( Ok(()) } +async fn create_channel( + request: proto::CreateChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let id = db + .create_channel( + &request.name, + request.parent_id.map(|id| ChannelId::from_proto(id)), + session.user_id, + ) + .await?; + + let mut update = proto::UpdateChannels::default(); + update.channels.push(proto::Channel { + id: id.to_proto(), + name: request.name, + parent_id: request.parent_id, + }); + session.peer.send(session.connection_id, update)?; + response.send(proto::CreateChannelResponse { + channel_id: id.to_proto(), + })?; + + Ok(()) +} + +async fn invite_channel_member( + request: proto::InviteChannelMember, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let channel = db.get_channel(channel_id).await?; + let invitee_id = UserId::from_proto(request.user_id); + db.invite_channel_member(channel_id, invitee_id, session.user_id, false) + .await?; + + let mut update = proto::UpdateChannels::default(); + update.channel_invitations.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + }); + for connection_id in session + .connection_pool() + .await + .user_connection_ids(invitee_id) + { + session.peer.send(connection_id, update.clone())?; + } + + response.send(proto::Ack {})?; + Ok(()) +} + +async fn remove_channel_member( + request: proto::RemoveChannelMember, + response: Response, + session: Session, +) -> Result<()> { + Ok(()) +} + +async fn respond_to_channel_invite( + request: proto::RespondToChannelInvite, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let channel = db.get_channel(channel_id).await?; + db.respond_to_channel_invite(channel_id, session.user_id, request.accept) + .await?; + + let mut update = proto::UpdateChannels::default(); + update + .remove_channel_invitations + .push(channel_id.to_proto()); + if request.accept { + update.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + }); + } + session.peer.send(session.connection_id, update)?; + response.send(proto::Ack {})?; + + Ok(()) +} + async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(request.project_id); let project_connection_ids = session diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 2e98cd9b4d2eccdbf61dc005f87c8c27ce76d9d2..cf302d3b4d9f5f3697a1b7cc0852bd656428fecb 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -7,7 +7,8 @@ use crate::{ use anyhow::anyhow; use call::ActiveCall; use client::{ - self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore, + self, proto::PeerId, ChannelStore, Client, Connection, Credentials, EstablishConnectionError, + UserStore, }; use collections::{HashMap, HashSet}; use fs::FakeFs; @@ -33,9 +34,9 @@ use std::{ use util::http::FakeHttpClient; use workspace::Workspace; +mod channel_tests; mod integration_tests; mod randomized_integration_tests; -mod channel_tests; struct TestServer { app_state: Arc, @@ -187,6 +188,8 @@ impl TestServer { let fs = FakeFs::new(cx.background()); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); + let channel_store = + cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx)); let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), @@ -218,6 +221,7 @@ impl TestServer { username: name.to_string(), state: Default::default(), user_store, + channel_store, fs, language_registry: Arc::new(LanguageRegistry::test()), }; @@ -320,6 +324,7 @@ struct TestClient { username: String, state: RefCell, pub user_store: ModelHandle, + pub channel_store: ModelHandle, language_registry: Arc, fs: Arc, } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 8ab33adcbf60490272d2540731c04ae5c008a6a0..4cc0d24d9bee9f108917b276d134f6bf51a9c423 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,85 +1,108 @@ +use client::Channel; use gpui::{executor::Deterministic, TestAppContext}; use std::sync::Arc; -use crate::db::Channel; - use super::TestServer; #[gpui::test] -async fn test_basic_channels(deterministic: Arc, cx: &mut TestAppContext) { +async fn test_basic_channels( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { deterministic.forbid_parking(); let mut server = TestServer::start(&deterministic).await; - let client_a = server.create_client(cx, "user_a").await; - let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); - let db = server._test_db.db(); + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; - let zed_id = db.create_root_channel("zed").await.unwrap(); - let crdb_id = db.create_channel("crdb", Some(zed_id)).await.unwrap(); - let livestreaming_id = db - .create_channel("livestreaming", Some(zed_id)) + let channel_a_id = client_a + .channel_store + .update(cx_a, |channel_store, _| { + channel_store.create_channel("channel-a", None) + }) .await .unwrap(); - let replace_id = db.create_channel("replace", Some(zed_id)).await.unwrap(); - let rust_id = db.create_root_channel("rust").await.unwrap(); - let cargo_id = db.create_channel("cargo", Some(rust_id)).await.unwrap(); - - db.add_channel_member(zed_id, a_id).await.unwrap(); - db.add_channel_member(rust_id, a_id).await.unwrap(); - let channels = db.get_channels(a_id).await.unwrap(); - assert_eq!( - channels, - vec![ - Channel { - id: zed_id, - name: "zed".to_string(), - parent_id: None, - }, - Channel { - id: rust_id, - name: "rust".to_string(), + client_a.channel_store.read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Channel { + id: channel_a_id, + name: "channel-a".to_string(), parent_id: None, - }, - Channel { - id: crdb_id, - name: "crdb".to_string(), - parent_id: Some(zed_id), - }, - Channel { - id: livestreaming_id, - name: "livestreaming".to_string(), - parent_id: Some(zed_id), - }, - Channel { - id: replace_id, - name: "replace".to_string(), - parent_id: Some(zed_id), - }, - Channel { - id: cargo_id, - name: "cargo".to_string(), - parent_id: Some(rust_id), - } - ] - ); -} + }] + ) + }); -#[gpui::test] -async fn test_block_cycle_creation(deterministic: Arc, cx: &mut TestAppContext) { - deterministic.forbid_parking(); - let mut server = TestServer::start(&deterministic).await; - let client_a = server.create_client(cx, "user_a").await; - let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); - let db = server._test_db.db(); + client_b + .channel_store + .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); - let zed_id = db.create_root_channel("zed").await.unwrap(); - let first_id = db.create_channel("first", Some(zed_id)).await.unwrap(); - let second_id = db - .create_channel("second_id", Some(first_id)) + // Invite client B to channel A as client A. + client_a + .channel_store + .update(cx_a, |channel_store, _| { + channel_store.invite_member(channel_a_id, client_b.user_id().unwrap(), false) + }) .await .unwrap(); + + // Wait for client b to see the invitation + deterministic.run_until_parked(); + + client_b.channel_store.read_with(cx_b, |channels, _| { + assert_eq!( + channels.channel_invitations(), + &[Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + }] + ) + }); + + // Client B now sees that they are in channel A. + client_b + .channel_store + .update(cx_b, |channels, _| { + channels.respond_to_channel_invite(channel_a_id, true) + }) + .await + .unwrap(); + client_b.channel_store.read_with(cx_b, |channels, _| { + assert_eq!(channels.channel_invitations(), &[]); + assert_eq!( + channels.channels(), + &[Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + }] + ) + }); } +// TODO: +// Invariants to test: +// 1. Dag structure is maintained for all operations (can't make a cycle) +// 2. Can't be a member of a super channel, and accept a membership of a sub channel (by definition, a noop) + +// #[gpui::test] +// async fn test_block_cycle_creation(deterministic: Arc, cx: &mut TestAppContext) { +// // deterministic.forbid_parking(); +// // let mut server = TestServer::start(&deterministic).await; +// // let client_a = server.create_client(cx, "user_a").await; +// // let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); +// // let db = server._test_db.db(); + +// // let zed_id = db.create_root_channel("zed", a_id).await.unwrap(); +// // let first_id = db.create_channel("first", Some(zed_id)).await.unwrap(); +// // let second_id = db +// // .create_channel("second_id", Some(first_id)) +// // .await +// // .unwrap(); +// } + /* Linear things: - A way of expressing progress to the team diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index a0b98372b10f05248e1c49fc14844b9de61274f9..38ffbe6b7e94dba6e5d90deacb9c85616972dab5 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -102,17 +102,6 @@ message Envelope { SearchProject search_project = 80; SearchProjectResponse search_project_response = 81; - GetChannels get_channels = 82; - GetChannelsResponse get_channels_response = 83; - JoinChannel join_channel = 84; - JoinChannelResponse join_channel_response = 85; - LeaveChannel leave_channel = 86; - SendChannelMessage send_channel_message = 87; - SendChannelMessageResponse send_channel_message_response = 88; - ChannelMessageSent channel_message_sent = 89; - GetChannelMessages get_channel_messages = 90; - GetChannelMessagesResponse get_channel_messages_response = 91; - UpdateContacts update_contacts = 92; UpdateInviteInfo update_invite_info = 93; ShowContacts show_contacts = 94; @@ -140,6 +129,13 @@ message Envelope { InlayHints inlay_hints = 116; InlayHintsResponse inlay_hints_response = 117; RefreshInlayHints refresh_inlay_hints = 118; + + CreateChannel create_channel = 119; + CreateChannelResponse create_channel_response = 120; + InviteChannelMember invite_channel_member = 121; + RemoveChannelMember remove_channel_member = 122; + RespondToChannelInvite respond_to_channel_invite = 123; + UpdateChannels update_channels = 124; } } @@ -867,23 +863,36 @@ message LspDiskBasedDiagnosticsUpdating {} message LspDiskBasedDiagnosticsUpdated {} -message GetChannels {} - -message GetChannelsResponse { +message UpdateChannels { repeated Channel channels = 1; + repeated uint64 remove_channels = 2; + repeated Channel channel_invitations = 3; + repeated uint64 remove_channel_invitations = 4; } -message JoinChannel { +message CreateChannel { + string name = 1; + optional uint64 parent_id = 2; +} + +message CreateChannelResponse { uint64 channel_id = 1; } -message JoinChannelResponse { - repeated ChannelMessage messages = 1; - bool done = 2; +message InviteChannelMember { + uint64 channel_id = 1; + uint64 user_id = 2; + bool admin = 3; } -message LeaveChannel { +message RemoveChannelMember { uint64 channel_id = 1; + uint64 user_id = 2; +} + +message RespondToChannelInvite { + uint64 channel_id = 1; + bool accept = 2; } message GetUsers { @@ -918,31 +927,6 @@ enum ContactRequestResponse { Dismiss = 3; } -message SendChannelMessage { - uint64 channel_id = 1; - string body = 2; - Nonce nonce = 3; -} - -message SendChannelMessageResponse { - ChannelMessage message = 1; -} - -message ChannelMessageSent { - uint64 channel_id = 1; - ChannelMessage message = 2; -} - -message GetChannelMessages { - uint64 channel_id = 1; - uint64 before_message_id = 2; -} - -message GetChannelMessagesResponse { - repeated ChannelMessage messages = 1; - bool done = 2; -} - message UpdateContacts { repeated Contact contacts = 1; repeated uint64 remove_contacts = 2; @@ -1274,14 +1258,7 @@ message Nonce { message Channel { uint64 id = 1; string name = 2; -} - -message ChannelMessage { - uint64 id = 1; - string body = 2; - uint64 timestamp = 3; - uint64 sender_id = 4; - Nonce nonce = 5; + optional uint64 parent_id = 3; } message Contact { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index e24d6cb4b74b407f4ab15083a7a257fb17e352ac..1e9e93a2d0188b4b669514354174b0b1b4083570 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -143,9 +143,10 @@ messages!( (Call, Foreground), (CallCanceled, Foreground), (CancelCall, Foreground), - (ChannelMessageSent, Foreground), (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), + (CreateChannel, Foreground), + (CreateChannelResponse, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), @@ -158,10 +159,6 @@ messages!( (FormatBuffers, Foreground), (FormatBuffersResponse, Foreground), (FuzzySearchUsers, Foreground), - (GetChannelMessages, Foreground), - (GetChannelMessagesResponse, Foreground), - (GetChannels, Foreground), - (GetChannelsResponse, Foreground), (GetCodeActions, Background), (GetCodeActionsResponse, Background), (GetHover, Background), @@ -181,14 +178,12 @@ messages!( (GetUsers, Foreground), (Hello, Foreground), (IncomingCall, Foreground), + (InviteChannelMember, Foreground), (UsersResponse, Foreground), - (JoinChannel, Foreground), - (JoinChannelResponse, Foreground), (JoinProject, Foreground), (JoinProjectResponse, Foreground), (JoinRoom, Foreground), (JoinRoomResponse, Foreground), - (LeaveChannel, Foreground), (LeaveProject, Foreground), (LeaveRoom, Foreground), (OpenBufferById, Background), @@ -211,18 +206,18 @@ messages!( (RejoinRoom, Foreground), (RejoinRoomResponse, Foreground), (RemoveContact, Foreground), + (RemoveChannelMember, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), (RemoveProjectCollaborator, Foreground), (RenameProjectEntry, Foreground), (RequestContact, Foreground), (RespondToContactRequest, Foreground), + (RespondToChannelInvite, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), - (SendChannelMessage, Foreground), - (SendChannelMessageResponse, Foreground), (ShareProject, Foreground), (ShareProjectResponse, Foreground), (ShowContacts, Foreground), @@ -235,6 +230,7 @@ messages!( (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), (UpdateContacts, Foreground), + (UpdateChannels, Foreground), (UpdateDiagnosticSummary, Foreground), (UpdateFollowers, Foreground), (UpdateInviteInfo, Foreground), @@ -260,13 +256,12 @@ request_messages!( (CopyProjectEntry, ProjectEntryResponse), (CreateProjectEntry, ProjectEntryResponse), (CreateRoom, CreateRoomResponse), + (CreateChannel, CreateChannelResponse), (DeclineCall, Ack), (DeleteProjectEntry, ProjectEntryResponse), (ExpandProjectEntry, ExpandProjectEntryResponse), (Follow, FollowResponse), (FormatBuffers, FormatBuffersResponse), - (GetChannelMessages, GetChannelMessagesResponse), - (GetChannels, GetChannelsResponse), (GetCodeActions, GetCodeActionsResponse), (GetHover, GetHoverResponse), (GetCompletions, GetCompletionsResponse), @@ -278,7 +273,7 @@ request_messages!( (GetProjectSymbols, GetProjectSymbolsResponse), (FuzzySearchUsers, UsersResponse), (GetUsers, UsersResponse), - (JoinChannel, JoinChannelResponse), + (InviteChannelMember, Ack), (JoinProject, JoinProjectResponse), (JoinRoom, JoinRoomResponse), (LeaveRoom, Ack), @@ -295,12 +290,13 @@ request_messages!( (RefreshInlayHints, Ack), (ReloadBuffers, ReloadBuffersResponse), (RequestContact, Ack), + (RemoveChannelMember, Ack), (RemoveContact, Ack), (RespondToContactRequest, Ack), + (RespondToChannelInvite, Ack), (RenameProjectEntry, ProjectEntryResponse), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), - (SendChannelMessage, SendChannelMessageResponse), (ShareProject, ShareProjectResponse), (SynchronizeBuffers, SynchronizeBuffersResponse), (Test, Test), @@ -363,8 +359,6 @@ entity_messages!( UpdateDiffBase ); -entity_messages!(channel_id, ChannelMessageSent); - const KIB: usize = 1024; const MIB: usize = KIB * 1024; const MAX_BUFFER_LEN: usize = MIB; From 92fa879b0c416e48ac25b8d978ad0824d25e544b Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 31 Jul 2023 15:27:10 -0700 Subject: [PATCH 017/105] Add ability to join a room from a channel ID co-authored-by: max --- crates/call/src/call.rs | 74 ++++++++++ crates/call/src/room.rs | 9 ++ crates/client/src/channel_store.rs | 4 +- .../20221109000000_test_schema.sql | 4 +- .../20230727150500_add_channels.sql | 3 +- crates/collab/src/db.rs | 129 +++++++++++++++--- crates/collab/src/db/channel.rs | 3 +- crates/collab/src/db/room.rs | 11 +- crates/collab/src/db/tests.rs | 84 ++++++++++-- crates/collab/src/rpc.rs | 115 ++++++++++------ crates/collab/src/tests.rs | 64 ++++++++- crates/collab/src/tests/channel_tests.rs | 55 ++++++++ crates/collab/src/tests/integration_tests.rs | 26 +--- crates/rpc/proto/zed.proto | 5 + crates/rpc/src/proto.rs | 2 + crates/rpc/src/rpc.rs | 2 +- 16 files changed, 485 insertions(+), 105 deletions(-) diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 2defd6b40f0f778157e6da24684b36d1cd565408..1e3a381b40837cc638c4679c4470e8049156e011 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -209,6 +209,80 @@ impl ActiveCall { }) } + pub fn join_channel( + &mut self, + channel_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let room = if let Some(room) = self.room().cloned() { + Some(Task::ready(Ok(room)).shared()) + } else { + self.pending_room_creation.clone() + }; + + todo!() + // let invite = if let Some(room) = room { + // cx.spawn_weak(|_, mut cx| async move { + // let room = room.await.map_err(|err| anyhow!("{:?}", err))?; + + // // TODO join_channel: + // // let initial_project_id = if let Some(initial_project) = initial_project { + // // Some( + // // room.update(&mut cx, |room, cx| room.share_project(initial_project, cx)) + // // .await?, + // // ) + // // } else { + // // None + // // }; + + // // room.update(&mut cx, |room, cx| { + // // room.call(called_user_id, initial_project_id, cx) + // // }) + // // .await?; + + // anyhow::Ok(()) + // }) + // } else { + // let client = self.client.clone(); + // let user_store = self.user_store.clone(); + // let room = cx + // .spawn(|this, mut cx| async move { + // let create_room = async { + // let room = cx + // .update(|cx| { + // Room::create_from_channel(channel_id, client, user_store, cx) + // }) + // .await?; + + // this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx)) + // .await?; + + // anyhow::Ok(room) + // }; + + // let room = create_room.await; + // this.update(&mut cx, |this, _| this.pending_room_creation = None); + // room.map_err(Arc::new) + // }) + // .shared(); + // self.pending_room_creation = Some(room.clone()); + // cx.foreground().spawn(async move { + // room.await.map_err(|err| anyhow!("{:?}", err))?; + // anyhow::Ok(()) + // }) + // }; + + // cx.spawn(|this, mut cx| async move { + // let result = invite.await; + // this.update(&mut cx, |this, cx| { + // this.pending_invites.remove(&called_user_id); + // this.report_call_event("invite", cx); + // cx.notify(); + // }); + // result + // }) + } + pub fn cancel_invite( &mut self, called_user_id: u64, diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 328a94506c136dad0fbf000b00b391d6c4025b7f..e77b5437b58a4ce2b0caa0a56ee6300b40744754 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -204,6 +204,15 @@ impl Room { } } + pub(crate) fn create_from_channel( + channel_id: u64, + client: Arc, + user_store: ModelHandle, + cx: &mut AppContext, + ) -> Task>> { + todo!() + } + pub(crate) fn create( called_user_id: u64, initial_project: Option>, diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index a72a189415bb2982126ee0c0fc3451ab9e2022d5..e78dafe4e87616ea26c03951e913e1052e581796 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -10,7 +10,7 @@ pub struct ChannelStore { channel_invitations: Vec, client: Arc, user_store: ModelHandle, - rpc_subscription: Subscription, + _rpc_subscription: Subscription, } #[derive(Debug, PartialEq)] @@ -37,7 +37,7 @@ impl ChannelStore { channel_invitations: vec![], client, user_store, - rpc_subscription, + _rpc_subscription: rpc_subscription, } } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 1ead36fde20eddbb689a32c72a77e2bf0ca7870a..6703f98df26acb9f3755c6667cef0c374a12bcfb 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -36,7 +36,8 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b"); CREATE TABLE "rooms" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "live_kit_room" VARCHAR NOT NULL + "live_kit_room" VARCHAR NOT NULL, + "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE ); CREATE TABLE "projects" ( @@ -188,7 +189,6 @@ CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); CREATE TABLE "channels" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR NOT NULL, - "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now ); diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql index 05886777929101b97db522af1be82fd17fe9bad7..2d94cb6d9769f9165382ede3074bd58e90f313a6 100644 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ b/crates/collab/migrations/20230727150500_add_channels.sql @@ -7,7 +7,6 @@ DROP TABLE "channels"; CREATE TABLE "channels" ( "id" SERIAL PRIMARY KEY, "name" VARCHAR NOT NULL, - "room_id" INTEGER REFERENCES rooms (id) ON DELETE SET NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now() ); @@ -27,3 +26,5 @@ CREATE TABLE "channel_members" ( ); CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id"); + +ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE; diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 46fca04658c90bc225d714c9f842e798a2aed294..5f106023f15ad91ad25fd9cf200f4f9332a76ea5 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1337,32 +1337,65 @@ impl Database { &self, room_id: RoomId, user_id: UserId, + channel_id: Option, connection: ConnectionId, ) -> Result> { self.room_transaction(room_id, |tx| async move { - let result = room_participant::Entity::update_many() - .filter( - Condition::all() - .add(room_participant::Column::RoomId.eq(room_id)) - .add(room_participant::Column::UserId.eq(user_id)) - .add(room_participant::Column::AnsweringConnectionId.is_null()), - ) - .set(room_participant::ActiveModel { + if let Some(channel_id) = channel_id { + channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(user_id)) + .and(channel_member::Column::Accepted.eq(true)), + ) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such channel membership"))?; + + room_participant::ActiveModel { + room_id: ActiveValue::set(room_id), + user_id: ActiveValue::set(user_id), answering_connection_id: ActiveValue::set(Some(connection.id as i32)), answering_connection_server_id: ActiveValue::set(Some(ServerId( connection.owner_id as i32, ))), answering_connection_lost: ActiveValue::set(false), + // Redundant for the channel join use case, used for channel and call invitations + calling_user_id: ActiveValue::set(user_id), + calling_connection_id: ActiveValue::set(connection.id as i32), + calling_connection_server_id: ActiveValue::set(Some(ServerId( + connection.owner_id as i32, + ))), ..Default::default() - }) - .exec(&*tx) + } + .insert(&*tx) .await?; - if result.rows_affected == 0 { - Err(anyhow!("room does not exist or was already joined"))? } else { - let room = self.get_room(room_id, &tx).await?; - Ok(room) + let result = room_participant::Entity::update_many() + .filter( + Condition::all() + .add(room_participant::Column::RoomId.eq(room_id)) + .add(room_participant::Column::UserId.eq(user_id)) + .add(room_participant::Column::AnsweringConnectionId.is_null()), + ) + .set(room_participant::ActiveModel { + answering_connection_id: ActiveValue::set(Some(connection.id as i32)), + answering_connection_server_id: ActiveValue::set(Some(ServerId( + connection.owner_id as i32, + ))), + answering_connection_lost: ActiveValue::set(false), + ..Default::default() + }) + .exec(&*tx) + .await?; + if result.rows_affected == 0 { + Err(anyhow!("room does not exist or was already joined"))?; + } } + + let room = self.get_room(room_id, &tx).await?; + Ok(room) }) .await } @@ -3071,6 +3104,14 @@ impl Database { .insert(&*tx) .await?; + room::ActiveModel { + channel_id: ActiveValue::Set(Some(channel.id)), + live_kit_room: ActiveValue::Set(format!("channel-{}", channel.id)), + ..Default::default() + } + .insert(&*tx) + .await?; + Ok(channel.id) }) .await @@ -3163,6 +3204,7 @@ impl Database { self.transaction(|tx| async move { let tx = tx; + // Breadth first list of all edges in this user's channels let sql = r#" WITH RECURSIVE channel_tree(child_id, parent_id, depth) AS ( SELECT channel_id as child_id, CAST(NULL as INTEGER) as parent_id, 0 @@ -3173,23 +3215,52 @@ impl Database { FROM channel_parents, channel_tree WHERE channel_parents.parent_id = channel_tree.child_id ) - SELECT channel_tree.child_id as id, channels.name, channel_tree.parent_id + SELECT channel_tree.child_id, channel_tree.parent_id FROM channel_tree - JOIN channels ON channels.id = channel_tree.child_id - ORDER BY channel_tree.depth; + ORDER BY child_id, parent_id IS NOT NULL "#; + #[derive(FromQueryResult, Debug, PartialEq)] + pub struct ChannelParent { + pub child_id: ChannelId, + pub parent_id: Option, + } + let stmt = Statement::from_sql_and_values( self.pool.get_database_backend(), sql, vec![user_id.into()], ); - Ok(channel_parent::Entity::find() + let mut parents_by_child_id = HashMap::default(); + let mut parents = channel_parent::Entity::find() .from_raw_sql(stmt) - .into_model::() - .all(&*tx) - .await?) + .into_model::() + .stream(&*tx).await?; + while let Some(parent) = parents.next().await { + let parent = parent?; + parents_by_child_id.insert(parent.child_id, parent.parent_id); + } + + drop(parents); + + let mut channels = Vec::with_capacity(parents_by_child_id.len()); + let mut rows = channel::Entity::find() + .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied())) + .stream(&*tx).await?; + + while let Some(row) = rows.next().await { + let row = row?; + channels.push(Channel { + id: row.id, + name: row.name, + parent_id: parents_by_child_id.get(&row.id).copied().flatten(), + }); + } + + drop(rows); + + Ok(channels) }) .await } @@ -3210,6 +3281,22 @@ impl Database { .await } + pub async fn get_channel_room(&self, channel_id: ChannelId) -> Result { + self.transaction(|tx| async move { + let tx = tx; + let room = channel::Model { + id: channel_id, + ..Default::default() + } + .find_related(room::Entity) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("invalid channel"))?; + Ok(room.id) + }) + .await + } + async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, diff --git a/crates/collab/src/db/channel.rs b/crates/collab/src/db/channel.rs index 48e5d50e3ea2d2f59a169a4500a466aa183d0d41..8834190645b1f3b5125e8d47bbcf2e3f76495ddc 100644 --- a/crates/collab/src/db/channel.rs +++ b/crates/collab/src/db/channel.rs @@ -1,4 +1,4 @@ -use super::{ChannelId, RoomId}; +use super::ChannelId; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] @@ -7,7 +7,6 @@ pub struct Model { #[sea_orm(primary_key)] pub id: ChannelId, pub name: String, - pub room_id: Option, } impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/room.rs b/crates/collab/src/db/room.rs index c838d1273b5a41751a7dbd6226725f7067c7abff..88514ef4f180b203e17185107ec1732837d1e524 100644 --- a/crates/collab/src/db/room.rs +++ b/crates/collab/src/db/room.rs @@ -1,4 +1,4 @@ -use super::RoomId; +use super::{ChannelId, RoomId}; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] @@ -7,6 +7,7 @@ pub struct Model { #[sea_orm(primary_key)] pub id: RoomId, pub live_kit_room: String, + pub channel_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -17,6 +18,12 @@ pub enum Relation { Project, #[sea_orm(has_many = "super::follower::Entity")] Follower, + #[sea_orm( + belongs_to = "super::channel::Entity", + from = "Column::ChannelId", + to = "super::channel::Column::Id" + )] + Channel, } impl Related for Entity { @@ -39,7 +46,7 @@ impl Related for Entity { impl Related for Entity { fn to() -> RelationDef { - Relation::Follower.def() + Relation::Channel.def() } } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 03e9eb577b930f74f48f8793f76008b3aabcec16..7ef2b39640a9cc5db09510951a8ed5ef0c9399fb 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -494,9 +494,14 @@ test_both_dbs!( ) .await .unwrap(); - db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 }) - .await - .unwrap(); + db.join_room( + room_id, + user2.user_id, + None, + ConnectionId { owner_id, id: 1 }, + ) + .await + .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0); db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[]) @@ -920,11 +925,6 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { name: "zed".to_string(), parent_id: None, }, - Channel { - id: rust_id, - name: "rust".to_string(), - parent_id: None, - }, Channel { id: crdb_id, name: "crdb".to_string(), @@ -940,6 +940,11 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { name: "replace".to_string(), parent_id: Some(zed_id), }, + Channel { + id: rust_id, + name: "rust".to_string(), + parent_id: None, + }, Channel { id: cargo_id, name: "cargo".to_string(), @@ -949,6 +954,69 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { ); }); +test_both_dbs!( + test_joining_channels_postgres, + test_joining_channels_sqlite, + db, + { + let owner_id = db.create_server("test").await.unwrap().0 as u32; + + let user_1 = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "user1".into(), + github_user_id: 5, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let user_2 = db + .create_user( + "user2@example.com", + false, + NewUserParams { + github_login: "user2".into(), + github_user_id: 6, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap(); + let room_1 = db.get_channel_room(channel_1).await.unwrap(); + + // can join a room with membership to its channel + let room = db + .join_room( + room_1, + user_1, + Some(channel_1), + ConnectionId { owner_id, id: 1 }, + ) + .await + .unwrap(); + assert_eq!(room.participants.len(), 1); + + drop(room); + // cannot join a room without membership to its channel + assert!(db + .join_room( + room_1, + user_2, + Some(channel_1), + ConnectionId { owner_id, id: 1 } + ) + .await + .is_err()); + } +); + #[gpui::test] async fn test_multiple_signup_overwrite() { let test_db = TestDb::postgres(build_background_executor()); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 3d95d484eed246ca5ea45c47be3f78db7c1c74d5..8cf0b7e48c37443f5b392ecc1ecfc980be7282d1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -34,7 +34,10 @@ use futures::{ use lazy_static::lazy_static; use prometheus::{register_int_gauge, IntGauge}; use rpc::{ - proto::{self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage}, + proto::{ + self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo, + RequestMessage, + }, Connection, ConnectionId, Peer, Receipt, TypedEnvelope, }; use serde::{Serialize, Serializer}; @@ -183,7 +186,7 @@ impl Server { server .add_request_handler(ping) - .add_request_handler(create_room) + .add_request_handler(create_room_request) .add_request_handler(join_room) .add_request_handler(rejoin_room) .add_request_handler(leave_room) @@ -243,6 +246,7 @@ impl Server { .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) .add_request_handler(respond_to_channel_invite) + .add_request_handler(join_channel) .add_request_handler(follow) .add_message_handler(unfollow) .add_message_handler(update_followers) @@ -855,48 +859,17 @@ async fn ping(_: proto::Ping, response: Response, _session: Session Ok(()) } -async fn create_room( +async fn create_room_request( _request: proto::CreateRoom, response: Response, session: Session, ) -> Result<()> { - let live_kit_room = nanoid::nanoid!(30); - let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() { - if let Some(_) = live_kit - .create_room(live_kit_room.clone()) - .await - .trace_err() - { - if let Some(token) = live_kit - .room_token(&live_kit_room, &session.user_id.to_string()) - .trace_err() - { - Some(proto::LiveKitConnectionInfo { - server_url: live_kit.url().into(), - token, - }) - } else { - None - } - } else { - None - } - } else { - None - }; - - { - let room = session - .db() - .await - .create_room(session.user_id, session.connection_id, &live_kit_room) - .await?; + let (room, live_kit_connection_info) = create_room(&session).await?; - response.send(proto::CreateRoomResponse { - room: Some(room.clone()), - live_kit_connection_info, - })?; - } + response.send(proto::CreateRoomResponse { + room: Some(room.clone()), + live_kit_connection_info, + })?; update_user_contacts(session.user_id, &session).await?; Ok(()) @@ -912,7 +885,7 @@ async fn join_room( let room = session .db() .await - .join_room(room_id, session.user_id, session.connection_id) + .join_room(room_id, session.user_id, None, session.connection_id) .await?; room_updated(&room, &session.peer); room.clone() @@ -2182,6 +2155,32 @@ async fn respond_to_channel_invite( Ok(()) } +async fn join_channel( + request: proto::JoinChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + + todo!(); + // db.check_channel_membership(session.user_id, channel_id) + // .await?; + + let (room, live_kit_connection_info) = create_room(&session).await?; + + // db.set_channel_room(channel_id, room.id).await?; + + response.send(proto::CreateRoomResponse { + room: Some(room.clone()), + live_kit_connection_info, + })?; + + update_user_contacts(session.user_id, &session).await?; + + Ok(()) +} + async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(request.project_id); let project_connection_ids = session @@ -2436,6 +2435,42 @@ fn project_left(project: &db::LeftProject, session: &Session) { } } +async fn create_room(session: &Session) -> Result<(proto::Room, Option)> { + let live_kit_room = nanoid::nanoid!(30); + + let live_kit_connection_info = { + let live_kit_room = live_kit_room.clone(); + let live_kit = session.live_kit_client.as_ref(); + + util::async_iife!({ + let live_kit = live_kit?; + + live_kit + .create_room(live_kit_room.clone()) + .await + .trace_err()?; + + let token = live_kit + .room_token(&live_kit_room, &session.user_id.to_string()) + .trace_err()?; + + Some(proto::LiveKitConnectionInfo { + server_url: live_kit.url().into(), + token, + }) + }) + } + .await; + + let room = session + .db() + .await + .create_room(session.user_id, session.connection_id, &live_kit_room) + .await?; + + Ok((room, live_kit_connection_info)) +} + pub trait ResultExt { type Ok; diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index cf302d3b4d9f5f3697a1b7cc0852bd656428fecb..a000fbd92e2f02594493954a4f4e36ded6c29476 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -5,7 +5,7 @@ use crate::{ AppState, }; use anyhow::anyhow; -use call::ActiveCall; +use call::{ActiveCall, Room}; use client::{ self, proto::PeerId, ChannelStore, Client, Connection, Credentials, EstablishConnectionError, UserStore, @@ -269,6 +269,44 @@ impl TestServer { } } + async fn make_channel( + &self, + channel: &str, + admin: (&TestClient, &mut TestAppContext), + members: &mut [(&TestClient, &mut TestAppContext)], + ) -> u64 { + let (admin_client, admin_cx) = admin; + let channel_id = admin_client + .channel_store + .update(admin_cx, |channel_store, _| { + channel_store.create_channel(channel, None) + }) + .await + .unwrap(); + + for (member_client, member_cx) in members { + admin_client + .channel_store + .update(admin_cx, |channel_store, _| { + channel_store.invite_member(channel_id, member_client.user_id().unwrap(), false) + }) + .await + .unwrap(); + + admin_cx.foreground().run_until_parked(); + + member_client + .channel_store + .update(*member_cx, |channels, _| { + channels.respond_to_channel_invite(channel_id, true) + }) + .await + .unwrap(); + } + + channel_id + } + async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) { self.make_contacts(clients).await; @@ -516,3 +554,27 @@ impl Drop for TestClient { self.client.teardown(); } } + +#[derive(Debug, Eq, PartialEq)] +struct RoomParticipants { + remote: Vec, + pending: Vec, +} + +fn room_participants(room: &ModelHandle, cx: &mut TestAppContext) -> RoomParticipants { + room.read_with(cx, |room, _| { + let mut remote = room + .remote_participants() + .iter() + .map(|(_, participant)| participant.user.github_login.clone()) + .collect::>(); + let mut pending = room + .pending_participants() + .iter() + .map(|user| user.github_login.clone()) + .collect::>(); + remote.sort(); + pending.sort(); + RoomParticipants { remote, pending } + }) +} diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 4cc0d24d9bee9f108917b276d134f6bf51a9c423..c86238825c6d35a4519d50c92763e6b799fa1ffc 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,7 +1,10 @@ +use call::ActiveCall; use client::Channel; use gpui::{executor::Deterministic, TestAppContext}; use std::sync::Arc; +use crate::tests::{room_participants, RoomParticipants}; + use super::TestServer; #[gpui::test] @@ -82,6 +85,58 @@ async fn test_basic_channels( }); } +#[gpui::test] +async fn test_channel_room( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let zed_id = server + .make_channel("zed", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + active_call_a + .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: vec![] + } + ); + + active_call_b + .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let active_call_b = cx_b.read(ActiveCall::global); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string(), "user_b".to_string()], + pending: vec![] + } + ); +} + // TODO: // Invariants to test: // 1. Dag structure is maintained for all operations (can't make a cycle) diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index ab94f16a07f011b8166f7b7ff779539b89401034..5a27787dbca7f83b39a2af50976c23c56fa4c995 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -1,6 +1,6 @@ use crate::{ rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, - tests::{TestClient, TestServer}, + tests::{room_participants, RoomParticipants, TestClient, TestServer}, }; use call::{room, ActiveCall, ParticipantLocation, Room}; use client::{User, RECEIVE_TIMEOUT}; @@ -8319,30 +8319,6 @@ async fn test_inlay_hint_refresh_is_forwarded( }); } -#[derive(Debug, Eq, PartialEq)] -struct RoomParticipants { - remote: Vec, - pending: Vec, -} - -fn room_participants(room: &ModelHandle, cx: &mut TestAppContext) -> RoomParticipants { - room.read_with(cx, |room, _| { - let mut remote = room - .remote_participants() - .iter() - .map(|(_, participant)| participant.user.github_login.clone()) - .collect::>(); - let mut pending = room - .pending_participants() - .iter() - .map(|user| user.github_login.clone()) - .collect::>(); - remote.sort(); - pending.sort(); - RoomParticipants { remote, pending } - }) -} - fn extract_hint_labels(editor: &Editor) -> Vec { let mut labels = Vec::new(); for (_, excerpt_hints) in &editor.inlay_hint_cache().hints { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 38ffbe6b7e94dba6e5d90deacb9c85616972dab5..8a4a72c26849f8d1b83a321787b4e97babcd7eb1 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -136,6 +136,7 @@ message Envelope { RemoveChannelMember remove_channel_member = 122; RespondToChannelInvite respond_to_channel_invite = 123; UpdateChannels update_channels = 124; + JoinChannel join_channel = 125; } } @@ -870,6 +871,10 @@ message UpdateChannels { repeated uint64 remove_channel_invitations = 4; } +message JoinChannel { + uint64 channel_id = 1; +} + message CreateChannel { string name = 1; optional uint64 parent_id = 2; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 1e9e93a2d0188b4b669514354174b0b1b4083570..c3d65343d62365b41845fc20407830e4e78acd9b 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -214,6 +214,7 @@ messages!( (RequestContact, Foreground), (RespondToContactRequest, Foreground), (RespondToChannelInvite, Foreground), + (JoinChannel, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), (SearchProject, Background), @@ -294,6 +295,7 @@ request_messages!( (RemoveContact, Ack), (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), + (JoinChannel, CreateRoomResponse), (RenameProjectEntry, ProjectEntryResponse), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 6b430d90e46072a6f885b60a4e912978ed26c6a2..3cb8b6bffa2ca1549ca854db39e46ef8fc8634a7 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -6,4 +6,4 @@ pub use conn::Connection; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 59; +pub const PROTOCOL_VERSION: u32 = 60; From 003a711deabb3c66b21dc950f35b9ae11edd67d5 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 31 Jul 2023 16:48:24 -0700 Subject: [PATCH 018/105] Add room creation from channel join co-authored-by: max --- crates/call/src/call.rs | 98 +++++------------- crates/call/src/room.rs | 59 +++++++++-- crates/collab/src/db.rs | 27 +++-- crates/collab/src/db/tests.rs | 20 ++-- crates/collab/src/rpc.rs | 126 +++++++++++++---------- crates/collab/src/tests/channel_tests.rs | 78 ++++++++------ crates/rpc/src/proto.rs | 2 +- 7 files changed, 229 insertions(+), 181 deletions(-) diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 1e3a381b40837cc638c4679c4470e8049156e011..3cd868a438daf607c75022e6963f0b48f067a389 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -209,80 +209,6 @@ impl ActiveCall { }) } - pub fn join_channel( - &mut self, - channel_id: u64, - cx: &mut ModelContext, - ) -> Task> { - let room = if let Some(room) = self.room().cloned() { - Some(Task::ready(Ok(room)).shared()) - } else { - self.pending_room_creation.clone() - }; - - todo!() - // let invite = if let Some(room) = room { - // cx.spawn_weak(|_, mut cx| async move { - // let room = room.await.map_err(|err| anyhow!("{:?}", err))?; - - // // TODO join_channel: - // // let initial_project_id = if let Some(initial_project) = initial_project { - // // Some( - // // room.update(&mut cx, |room, cx| room.share_project(initial_project, cx)) - // // .await?, - // // ) - // // } else { - // // None - // // }; - - // // room.update(&mut cx, |room, cx| { - // // room.call(called_user_id, initial_project_id, cx) - // // }) - // // .await?; - - // anyhow::Ok(()) - // }) - // } else { - // let client = self.client.clone(); - // let user_store = self.user_store.clone(); - // let room = cx - // .spawn(|this, mut cx| async move { - // let create_room = async { - // let room = cx - // .update(|cx| { - // Room::create_from_channel(channel_id, client, user_store, cx) - // }) - // .await?; - - // this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx)) - // .await?; - - // anyhow::Ok(room) - // }; - - // let room = create_room.await; - // this.update(&mut cx, |this, _| this.pending_room_creation = None); - // room.map_err(Arc::new) - // }) - // .shared(); - // self.pending_room_creation = Some(room.clone()); - // cx.foreground().spawn(async move { - // room.await.map_err(|err| anyhow!("{:?}", err))?; - // anyhow::Ok(()) - // }) - // }; - - // cx.spawn(|this, mut cx| async move { - // let result = invite.await; - // this.update(&mut cx, |this, cx| { - // this.pending_invites.remove(&called_user_id); - // this.report_call_event("invite", cx); - // cx.notify(); - // }); - // result - // }) - } - pub fn cancel_invite( &mut self, called_user_id: u64, @@ -348,6 +274,30 @@ impl ActiveCall { Ok(()) } + pub fn join_channel( + &mut self, + channel_id: u64, + cx: &mut ModelContext, + ) -> Task> { + if let Some(room) = self.room().cloned() { + if room.read(cx).channel_id() == Some(channel_id) { + return Task::ready(Ok(())); + } + } + + let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx)) + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("join channel", cx) + }); + Ok(()) + }) + } + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { cx.notify(); self.report_call_event("hang up", cx); diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index e77b5437b58a4ce2b0caa0a56ee6300b40744754..683ff6f4df3b024b381f6f8f979953ce1fddb2a4 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -49,6 +49,7 @@ pub enum Event { pub struct Room { id: u64, + channel_id: Option, live_kit: Option, status: RoomStatus, shared_projects: HashSet>, @@ -93,8 +94,25 @@ impl Entity for Room { } impl Room { + pub fn channel_id(&self) -> Option { + self.channel_id + } + + #[cfg(any(test, feature = "test-support"))] + pub fn is_connected(&self) -> bool { + if let Some(live_kit) = self.live_kit.as_ref() { + matches!( + *live_kit.room.status().borrow(), + live_kit_client::ConnectionState::Connected { .. } + ) + } else { + false + } + } + fn new( id: u64, + channel_id: Option, live_kit_connection_info: Option, client: Arc, user_store: ModelHandle, @@ -185,6 +203,7 @@ impl Room { Self { id, + channel_id, live_kit: live_kit_room, status: RoomStatus::Online, shared_projects: Default::default(), @@ -204,15 +223,6 @@ impl Room { } } - pub(crate) fn create_from_channel( - channel_id: u64, - client: Arc, - user_store: ModelHandle, - cx: &mut AppContext, - ) -> Task>> { - todo!() - } - pub(crate) fn create( called_user_id: u64, initial_project: Option>, @@ -226,6 +236,7 @@ impl Room { let room = cx.add_model(|cx| { Self::new( room_proto.id, + None, response.live_kit_connection_info, client, user_store, @@ -257,6 +268,35 @@ impl Room { }) } + pub(crate) fn join_channel( + channel_id: u64, + client: Arc, + user_store: ModelHandle, + cx: &mut AppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let response = client.request(proto::JoinChannel { channel_id }).await?; + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.add_model(|cx| { + Self::new( + room_proto.id, + Some(channel_id), + response.live_kit_connection_info, + client, + user_store, + cx, + ) + }); + + room.update(&mut cx, |room, cx| { + room.apply_room_update(room_proto, cx)?; + anyhow::Ok(()) + })?; + + Ok(room) + }) + } + pub(crate) fn join( call: &IncomingCall, client: Arc, @@ -270,6 +310,7 @@ impl Room { let room = cx.add_model(|cx| { Self::new( room_id, + None, response.live_kit_connection_info, client, user_store, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 5f106023f15ad91ad25fd9cf200f4f9332a76ea5..f87b68c1ec082b8612565e0ddc36b0e7f0ab70a0 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1833,14 +1833,21 @@ impl Database { .await?; let room = self.get_room(room_id, &tx).await?; - if room.participants.is_empty() { - room::Entity::delete_by_id(room_id).exec(&*tx).await?; - } + let deleted = if room.participants.is_empty() { + let result = room::Entity::delete_by_id(room_id) + .filter(room::Column::ChannelId.is_null()) + .exec(&*tx) + .await?; + result.rows_affected > 0 + } else { + false + }; let left_room = LeftRoom { room, left_projects, canceled_calls_to_user_ids, + deleted, }; if left_room.room.participants.is_empty() { @@ -3065,14 +3072,21 @@ impl Database { // channels - pub async fn create_root_channel(&self, name: &str, creator_id: UserId) -> Result { - self.create_channel(name, None, creator_id).await + pub async fn create_root_channel( + &self, + name: &str, + live_kit_room: &str, + creator_id: UserId, + ) -> Result { + self.create_channel(name, None, live_kit_room, creator_id) + .await } pub async fn create_channel( &self, name: &str, parent: Option, + live_kit_room: &str, creator_id: UserId, ) -> Result { self.transaction(move |tx| async move { @@ -3106,7 +3120,7 @@ impl Database { room::ActiveModel { channel_id: ActiveValue::Set(Some(channel.id)), - live_kit_room: ActiveValue::Set(format!("channel-{}", channel.id)), + live_kit_room: ActiveValue::Set(live_kit_room.to_string()), ..Default::default() } .insert(&*tx) @@ -3731,6 +3745,7 @@ pub struct LeftRoom { pub room: proto::Room, pub left_projects: HashMap, pub canceled_calls_to_user_ids: Vec, + pub deleted: bool, } pub struct RefreshedRoom { diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 7ef2b39640a9cc5db09510951a8ed5ef0c9399fb..719e8693d4f29efe36724eb4d4b657d986e246e6 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -899,19 +899,22 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .unwrap() .user_id; - let zed_id = db.create_root_channel("zed", a_id).await.unwrap(); - let crdb_id = db.create_channel("crdb", Some(zed_id), a_id).await.unwrap(); + let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap(); + let crdb_id = db + .create_channel("crdb", Some(zed_id), "2", a_id) + .await + .unwrap(); let livestreaming_id = db - .create_channel("livestreaming", Some(zed_id), a_id) + .create_channel("livestreaming", Some(zed_id), "3", a_id) .await .unwrap(); let replace_id = db - .create_channel("replace", Some(zed_id), a_id) + .create_channel("replace", Some(zed_id), "4", a_id) .await .unwrap(); - let rust_id = db.create_root_channel("rust", a_id).await.unwrap(); + let rust_id = db.create_root_channel("rust", "5", a_id).await.unwrap(); let cargo_id = db - .create_channel("cargo", Some(rust_id), a_id) + .create_channel("cargo", Some(rust_id), "6", a_id) .await .unwrap(); @@ -988,7 +991,10 @@ test_both_dbs!( .unwrap() .user_id; - let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap(); + let channel_1 = db + .create_root_channel("channel_1", "1", user_1) + .await + .unwrap(); let room_1 = db.get_channel_room(channel_1).await.unwrap(); // can join a room with membership to its channel diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 8cf0b7e48c37443f5b392ecc1ecfc980be7282d1..0abf2c44a7b4f843287625cda415730402a1d00e 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -186,7 +186,7 @@ impl Server { server .add_request_handler(ping) - .add_request_handler(create_room_request) + .add_request_handler(create_room) .add_request_handler(join_room) .add_request_handler(rejoin_room) .add_request_handler(leave_room) @@ -859,12 +859,42 @@ async fn ping(_: proto::Ping, response: Response, _session: Session Ok(()) } -async fn create_room_request( +async fn create_room( _request: proto::CreateRoom, response: Response, session: Session, ) -> Result<()> { - let (room, live_kit_connection_info) = create_room(&session).await?; + let live_kit_room = nanoid::nanoid!(30); + + let live_kit_connection_info = { + let live_kit_room = live_kit_room.clone(); + let live_kit = session.live_kit_client.as_ref(); + + util::async_iife!({ + let live_kit = live_kit?; + + live_kit + .create_room(live_kit_room.clone()) + .await + .trace_err()?; + + let token = live_kit + .room_token(&live_kit_room, &session.user_id.to_string()) + .trace_err()?; + + Some(proto::LiveKitConnectionInfo { + server_url: live_kit.url().into(), + token, + }) + }) + } + .await; + + let room = session + .db() + .await + .create_room(session.user_id, session.connection_id, &live_kit_room) + .await?; response.send(proto::CreateRoomResponse { room: Some(room.clone()), @@ -1259,11 +1289,12 @@ async fn update_participant_location( let location = request .location .ok_or_else(|| anyhow!("invalid location"))?; - let room = session - .db() - .await + + let db = session.db().await; + let room = db .update_room_participant_location(room_id, session.connection_id, location) .await?; + room_updated(&room, &session.peer); response.send(proto::Ack {})?; Ok(()) @@ -2067,10 +2098,17 @@ async fn create_channel( session: Session, ) -> Result<()> { let db = session.db().await; + let live_kit_room = format!("channel-{}", nanoid::nanoid!(30)); + + if let Some(live_kit) = session.live_kit_client.as_ref() { + live_kit.create_room(live_kit_room.clone()).await?; + } + let id = db .create_channel( &request.name, request.parent_id.map(|id| ChannelId::from_proto(id)), + &live_kit_room, session.user_id, ) .await?; @@ -2160,21 +2198,39 @@ async fn join_channel( response: Response, session: Session, ) -> Result<()> { - let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - todo!(); - // db.check_channel_membership(session.user_id, channel_id) - // .await?; + { + let db = session.db().await; + let room_id = db.get_channel_room(channel_id).await?; + + let room = db + .join_room( + room_id, + session.user_id, + Some(channel_id), + session.connection_id, + ) + .await?; + + let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| { + let token = live_kit + .room_token(&room.live_kit_room, &session.user_id.to_string()) + .trace_err()?; - let (room, live_kit_connection_info) = create_room(&session).await?; + Some(LiveKitConnectionInfo { + server_url: live_kit.url().into(), + token, + }) + }); - // db.set_channel_room(channel_id, room.id).await?; + response.send(proto::JoinRoomResponse { + room: Some(room.clone()), + live_kit_connection_info, + })?; - response.send(proto::CreateRoomResponse { - room: Some(room.clone()), - live_kit_connection_info, - })?; + room_updated(&room, &session.peer); + } update_user_contacts(session.user_id, &session).await?; @@ -2367,7 +2423,7 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { room_id = RoomId::from_proto(left_room.room.id); canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids); live_kit_room = mem::take(&mut left_room.room.live_kit_room); - delete_live_kit_room = left_room.room.participants.is_empty(); + delete_live_kit_room = left_room.deleted; } else { return Ok(()); } @@ -2435,42 +2491,6 @@ fn project_left(project: &db::LeftProject, session: &Session) { } } -async fn create_room(session: &Session) -> Result<(proto::Room, Option)> { - let live_kit_room = nanoid::nanoid!(30); - - let live_kit_connection_info = { - let live_kit_room = live_kit_room.clone(); - let live_kit = session.live_kit_client.as_ref(); - - util::async_iife!({ - let live_kit = live_kit?; - - live_kit - .create_room(live_kit_room.clone()) - .await - .trace_err()?; - - let token = live_kit - .room_token(&live_kit_room, &session.user_id.to_string()) - .trace_err()?; - - Some(proto::LiveKitConnectionInfo { - server_url: live_kit.url().into(), - token, - }) - }) - } - .await; - - let room = session - .db() - .await - .create_room(session.user_id, session.connection_id, &live_kit_room) - .await?; - - Ok((room, live_kit_connection_info)) -} - pub trait ResultExt { type Ok; diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index c86238825c6d35a4519d50c92763e6b799fa1ffc..632bfdca49df8438aecd83a720ab60e9708934dd 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -108,17 +108,52 @@ async fn test_channel_room( .await .unwrap(); + active_call_b + .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) + .await + .unwrap(); + deterministic.run_until_parked(); let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + room_a.read_with(cx_a, |room, _| assert!(room.is_connected())); assert_eq!( room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec![] + } + ); + + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + room_b.read_with(cx_b, |room, _| assert!(room.is_connected())); + assert_eq!( + room_participants(&room_b, cx_b), RoomParticipants { remote: vec!["user_a".to_string()], pending: vec![] } ); + // Make sure that leaving and rejoining works + + active_call_a + .update(cx_a, |active_call, cx| active_call.hang_up(cx)) + .await + .unwrap(); + + active_call_b + .update(cx_b, |active_call, cx| active_call.hang_up(cx)) + .await + .unwrap(); + + // Make sure room exists? + + active_call_a + .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) + .await + .unwrap(); + active_call_b .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) .await @@ -126,42 +161,23 @@ async fn test_channel_room( deterministic.run_until_parked(); - let active_call_b = cx_b.read(ActiveCall::global); + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + room_a.read_with(cx_a, |room, _| assert!(room.is_connected())); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec![] + } + ); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + room_b.read_with(cx_b, |room, _| assert!(room.is_connected())); assert_eq!( room_participants(&room_b, cx_b), RoomParticipants { - remote: vec!["user_a".to_string(), "user_b".to_string()], + remote: vec!["user_a".to_string()], pending: vec![] } ); } - -// TODO: -// Invariants to test: -// 1. Dag structure is maintained for all operations (can't make a cycle) -// 2. Can't be a member of a super channel, and accept a membership of a sub channel (by definition, a noop) - -// #[gpui::test] -// async fn test_block_cycle_creation(deterministic: Arc, cx: &mut TestAppContext) { -// // deterministic.forbid_parking(); -// // let mut server = TestServer::start(&deterministic).await; -// // let client_a = server.create_client(cx, "user_a").await; -// // let a_id = crate::db::UserId(client_a.user_id().unwrap() as i32); -// // let db = server._test_db.db(); - -// // let zed_id = db.create_root_channel("zed", a_id).await.unwrap(); -// // let first_id = db.create_channel("first", Some(zed_id)).await.unwrap(); -// // let second_id = db -// // .create_channel("second_id", Some(first_id)) -// // .await -// // .unwrap(); -// } - -/* -Linear things: -- A way of expressing progress to the team -- A way for us to agree on a scope -- A way to figure out what we're supposed to be doing - -*/ diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index c3d65343d62365b41845fc20407830e4e78acd9b..d71ddeed830f7ead89115508f1c5db888dd22eb8 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -295,7 +295,7 @@ request_messages!( (RemoveContact, Ack), (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), - (JoinChannel, CreateRoomResponse), + (JoinChannel, JoinRoomResponse), (RenameProjectEntry, ProjectEntryResponse), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), From 7954b02819bcdfb3573624394f53979f04d0879d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 31 Jul 2023 18:00:14 -0700 Subject: [PATCH 019/105] Start work on displaying channels and invites in collab panel --- crates/client/src/channel_store.rs | 125 +++++++------ crates/client/src/channel_store_tests.rs | 95 ++++++++++ crates/client/src/client.rs | 3 + crates/collab/src/tests.rs | 1 + crates/collab/src/tests/channel_tests.rs | 15 +- crates/collab_ui/src/panel.rs | 215 ++++++++++++++++++++++- crates/workspace/src/workspace.rs | 19 +- crates/zed/src/main.rs | 7 +- 8 files changed, 411 insertions(+), 69 deletions(-) create mode 100644 crates/client/src/channel_store_tests.rs diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index e78dafe4e87616ea26c03951e913e1052e581796..678e712c7d50cdf4817ec3a05b42570b84a633ec 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -6,18 +6,19 @@ use rpc::{proto, TypedEnvelope}; use std::sync::Arc; pub struct ChannelStore { - channels: Vec, - channel_invitations: Vec, + channels: Vec>, + channel_invitations: Vec>, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, } -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Channel { pub id: u64, pub name: String, pub parent_id: Option, + pub depth: usize, } impl Entity for ChannelStore { @@ -41,11 +42,11 @@ impl ChannelStore { } } - pub fn channels(&self) -> &[Channel] { + pub fn channels(&self) -> &[Arc] { &self.channels } - pub fn channel_invitations(&self) -> &[Channel] { + pub fn channel_invitations(&self) -> &[Arc] { &self.channel_invitations } @@ -97,6 +98,10 @@ impl ChannelStore { } } + pub fn is_channel_invite_pending(&self, channel: &Arc) -> bool { + false + } + pub fn remove_member( &self, channel_id: u64, @@ -124,66 +129,74 @@ impl ChannelStore { _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { - let payload = message.payload; this.update(&mut cx, |this, cx| { - this.channels - .retain(|channel| !payload.remove_channels.contains(&channel.id)); - this.channel_invitations - .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); - - for channel in payload.channel_invitations { - if let Some(existing_channel) = this - .channel_invitations - .iter_mut() - .find(|c| c.id == channel.id) - { - existing_channel.name = channel.name; - continue; - } + this.update_channels(message.payload, cx); + }); + Ok(()) + } - this.channel_invitations.insert( - 0, - Channel { - id: channel.id, - name: channel.name, - parent_id: None, - }, - ); + pub(crate) fn update_channels( + &mut self, + payload: proto::UpdateChannels, + cx: &mut ModelContext, + ) { + self.channels + .retain(|channel| !payload.remove_channels.contains(&channel.id)); + self.channel_invitations + .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); + + for channel in payload.channel_invitations { + if let Some(existing_channel) = self + .channel_invitations + .iter_mut() + .find(|c| c.id == channel.id) + { + Arc::make_mut(existing_channel).name = channel.name; + continue; } - for channel in payload.channels { - if let Some(existing_channel) = - this.channels.iter_mut().find(|c| c.id == channel.id) - { - existing_channel.name = channel.name; - continue; - } + self.channel_invitations.insert( + 0, + Arc::new(Channel { + id: channel.id, + name: channel.name, + parent_id: None, + depth: 0, + }), + ); + } + + for channel in payload.channels { + if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { + Arc::make_mut(existing_channel).name = channel.name; + continue; + } - if let Some(parent_id) = channel.parent_id { - if let Some(ix) = this.channels.iter().position(|c| c.id == parent_id) { - this.channels.insert( - ix + 1, - Channel { - id: channel.id, - name: channel.name, - parent_id: Some(parent_id), - }, - ); - } - } else { - this.channels.insert( - 0, - Channel { + if let Some(parent_id) = channel.parent_id { + if let Some(ix) = self.channels.iter().position(|c| c.id == parent_id) { + let depth = self.channels[ix].depth + 1; + self.channels.insert( + ix + 1, + Arc::new(Channel { id: channel.id, name: channel.name, - parent_id: None, - }, + parent_id: Some(parent_id), + depth, + }), ); } + } else { + self.channels.insert( + 0, + Arc::new(Channel { + id: channel.id, + name: channel.name, + parent_id: None, + depth: 0, + }), + ); } - cx.notify(); - }); - - Ok(()) + } + cx.notify(); } } diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0d4ec6ce3589ee1cf007b30c8dbc9871c98b447b --- /dev/null +++ b/crates/client/src/channel_store_tests.rs @@ -0,0 +1,95 @@ +use util::http::FakeHttpClient; + +use super::*; + +#[gpui::test] +fn test_update_channels(cx: &mut AppContext) { + let http = FakeHttpClient::with_404_response(); + let client = Client::new(http.clone(), cx); + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); + + let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx)); + + update_channels( + &channel_store, + proto::UpdateChannels { + channels: vec![ + proto::Channel { + id: 1, + name: "b".to_string(), + parent_id: None, + }, + proto::Channel { + id: 2, + name: "a".to_string(), + parent_id: None, + }, + ], + ..Default::default() + }, + cx, + ); + assert_channels( + &channel_store, + &[ + // + (0, "a"), + (0, "b"), + ], + cx, + ); + + update_channels( + &channel_store, + proto::UpdateChannels { + channels: vec![ + proto::Channel { + id: 3, + name: "x".to_string(), + parent_id: Some(1), + }, + proto::Channel { + id: 4, + name: "y".to_string(), + parent_id: Some(2), + }, + ], + ..Default::default() + }, + cx, + ); + assert_channels( + &channel_store, + &[ + // + (0, "a"), + (1, "y"), + (0, "b"), + (1, "x"), + ], + cx, + ); +} + +fn update_channels( + channel_store: &ModelHandle, + message: proto::UpdateChannels, + cx: &mut AppContext, +) { + channel_store.update(cx, |store, cx| store.update_channels(message, cx)); +} + +fn assert_channels( + channel_store: &ModelHandle, + expected_channels: &[(usize, &str)], + cx: &AppContext, +) { + channel_store.read_with(cx, |store, _| { + let actual = store + .channels() + .iter() + .map(|c| (c.depth, c.name.as_str())) + .collect::>(); + assert_eq!(actual, expected_channels); + }); +} diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index af33c738ce4bb7729768e5f2cf10a338abe3ab10..a48b2849ae18c737a6264ff5be187b4010dc262f 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,6 +1,9 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; +#[cfg(test)] +mod channel_store_tests; + pub mod channel_store; pub mod telemetry; pub mod user; diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index a000fbd92e2f02594493954a4f4e36ded6c29476..98ad2afb8af0dfe5571cb4029e1cf4c352ce24db 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -193,6 +193,7 @@ impl TestServer { let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), + channel_store: channel_store.clone(), languages: Arc::new(LanguageRegistry::test()), fs: fs.clone(), build_window_options: |_, _, _| Default::default(), diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 632bfdca49df8438aecd83a720ab60e9708934dd..ffd517f52ac4a926bc58d1eb965d254b02bb7745 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -29,11 +29,12 @@ async fn test_basic_channels( client_a.channel_store.read_with(cx_a, |channels, _| { assert_eq!( channels.channels(), - &[Channel { + &[Arc::new(Channel { id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - }] + depth: 0, + })] ) }); @@ -56,11 +57,12 @@ async fn test_basic_channels( client_b.channel_store.read_with(cx_b, |channels, _| { assert_eq!( channels.channel_invitations(), - &[Channel { + &[Arc::new(Channel { id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - }] + depth: 0, + })] ) }); @@ -76,11 +78,12 @@ async fn test_basic_channels( assert_eq!(channels.channel_invitations(), &[]); assert_eq!( channels.channels(), - &[Channel { + &[Arc::new(Channel { id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - }] + depth: 0, + })] ) }); } diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bdeac59af9cbfefe6204df44ce922d2bbf3a5f0e..bdd01e42998beadf459ca66888e125fc28cb6366 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -4,7 +4,7 @@ mod panel_settings; use anyhow::Result; use call::ActiveCall; -use client::{proto::PeerId, Client, Contact, User, UserStore}; +use client::{proto::PeerId, Channel, ChannelStore, Client, Contact, User, UserStore}; use contact_finder::build_contact_finder; use context_menu::ContextMenu; use db::kvp::KEY_VALUE_STORE; @@ -62,6 +62,7 @@ pub struct CollabPanel { entries: Vec, selection: Option, user_store: ModelHandle, + channel_store: ModelHandle, project: ModelHandle, match_candidates: Vec, list_state: ListState, @@ -109,8 +110,10 @@ enum ContactEntry { peer_id: PeerId, is_last: bool, }, + ChannelInvite(Arc), IncomingRequest(Arc), OutgoingRequest(Arc), + Channel(Arc), Contact { contact: Arc, calling: bool, @@ -204,6 +207,16 @@ impl CollabPanel { cx, ) } + ContactEntry::Channel(channel) => { + Self::render_channel(&*channel, &theme.collab_panel, is_selected, cx) + } + ContactEntry::ChannelInvite(channel) => Self::render_channel_invite( + channel.clone(), + this.channel_store.clone(), + &theme.collab_panel, + is_selected, + cx, + ), ContactEntry::IncomingRequest(user) => Self::render_contact_request( user.clone(), this.user_store.clone(), @@ -241,6 +254,7 @@ impl CollabPanel { entries: Vec::default(), selection: None, user_store: workspace.user_store().clone(), + channel_store: workspace.app_state().channel_store.clone(), project: workspace.project().clone(), subscriptions: Vec::default(), match_candidates: Vec::default(), @@ -320,6 +334,7 @@ impl CollabPanel { } fn update_entries(&mut self, cx: &mut ViewContext) { + let channel_store = self.channel_store.read(cx); let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); let executor = cx.background().clone(); @@ -445,10 +460,65 @@ impl CollabPanel { self.entries .push(ContactEntry::Header(Section::Channels, 0)); + let channels = channel_store.channels(); + if !channels.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend( + channels + .iter() + .enumerate() + .map(|(ix, channel)| StringMatchCandidate { + id: ix, + string: channel.name.clone(), + char_bag: channel.name.chars().collect(), + }), + ); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + self.entries.extend( + matches + .iter() + .map(|mat| ContactEntry::Channel(channels[mat.candidate_id].clone())), + ); + } + self.entries .push(ContactEntry::Header(Section::Contacts, 0)); let mut request_entries = Vec::new(); + let channel_invites = channel_store.channel_invitations(); + if !channel_invites.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { + StringMatchCandidate { + id: ix, + string: channel.name.clone(), + char_bag: channel.name.chars().collect(), + } + })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend( + matches.iter().map(|mat| { + ContactEntry::ChannelInvite(channel_invites[mat.candidate_id].clone()) + }), + ); + } + let incoming = user_store.incoming_contact_requests(); if !incoming.is_empty() { self.match_candidates.clear(); @@ -1112,6 +1182,121 @@ impl CollabPanel { event_handler.into_any() } + fn render_channel( + channel: &Channel, + theme: &theme::CollabPanel, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + let channel_id = channel.id; + MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { + Flex::row() + .with_child({ + Svg::new("icons/hash") + // .with_style(theme.contact_avatar) + .aligned() + .left() + }) + .with_child( + Label::new(channel.name.clone(), theme.contact_username.text.clone()) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style(*theme.contact_row.in_state(is_selected).style_for(state)) + }) + .on_click(MouseButton::Left, move |_, this, cx| { + this.join_channel(channel_id, cx); + }) + .into_any() + } + + fn render_channel_invite( + channel: Arc, + user_store: ModelHandle, + theme: &theme::CollabPanel, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + enum Decline {} + enum Accept {} + + let channel_id = channel.id; + let is_invite_pending = user_store.read(cx).is_channel_invite_pending(&channel); + let button_spacing = theme.contact_button_spacing; + + Flex::row() + .with_child({ + Svg::new("icons/hash") + // .with_style(theme.contact_avatar) + .aligned() + .left() + }) + .with_child( + Label::new(channel.name.clone(), theme.contact_username.text.clone()) + .contained() + .with_style(theme.contact_username.container) + .aligned() + .left() + .flex(1., true), + ) + .with_child( + MouseEventHandler::::new( + channel.id as usize, + cx, + |mouse_state, _| { + let button_style = if is_invite_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state) + }; + render_icon_button(button_style, "icons/x_mark_8.svg").aligned() + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.respond_to_channel_invite(channel_id, false, cx); + }) + .contained() + .with_margin_right(button_spacing), + ) + .with_child( + MouseEventHandler::::new( + channel.id as usize, + cx, + |mouse_state, _| { + let button_style = if is_invite_pending { + &theme.disabled_button + } else { + theme.contact_button.style_for(mouse_state) + }; + render_icon_button(button_style, "icons/check_8.svg") + .aligned() + .flex_float() + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.respond_to_channel_invite(channel_id, true, cx); + }), + ) + .constrained() + .with_height(theme.row_height) + .contained() + .with_style( + *theme + .contact_row + .in_state(is_selected) + .style_for(&mut Default::default()), + ) + .into_any() + } + fn render_contact_request( user: Arc, user_store: ModelHandle, @@ -1384,6 +1569,18 @@ impl CollabPanel { .detach(); } + fn respond_to_channel_invite( + &mut self, + channel_id: u64, + accept: bool, + cx: &mut ViewContext, + ) { + let respond = self.channel_store.update(cx, |store, _| { + store.respond_to_channel_invite(channel_id, accept) + }); + cx.foreground().spawn(respond).detach(); + } + fn call( &mut self, recipient_user_id: u64, @@ -1396,6 +1593,12 @@ impl CollabPanel { }) .detach_and_log_err(cx); } + + fn join_channel(&self, channel: u64, cx: &mut ViewContext) { + ActiveCall::global(cx) + .update(cx, |call, cx| call.join_channel(channel, cx)) + .detach_and_log_err(cx); + } } impl View for CollabPanel { @@ -1557,6 +1760,16 @@ impl PartialEq for ContactEntry { return peer_id_1 == peer_id_2; } } + ContactEntry::Channel(channel_1) => { + if let ContactEntry::Channel(channel_2) = other { + return channel_1.id == channel_2.id; + } + } + ContactEntry::ChannelInvite(channel_1) => { + if let ContactEntry::ChannelInvite(channel_2) = other { + return channel_1.id == channel_2.id; + } + } ContactEntry::IncomingRequest(user_1) => { if let ContactEntry::IncomingRequest(user_2) = other { return user_1.id == user_2.id; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 434975216ad977df75cdd960f27fdb6ed4306f02..95077649a8b66467240e90f352f3bb321a9ffee9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -14,7 +14,7 @@ use anyhow::{anyhow, Context, Result}; use call::ActiveCall; use client::{ proto::{self, PeerId}, - Client, TypedEnvelope, UserStore, + ChannelStore, Client, TypedEnvelope, UserStore, }; use collections::{hash_map, HashMap, HashSet}; use drag_and_drop::DragAndDrop; @@ -400,8 +400,9 @@ pub fn register_deserializable_item(cx: &mut AppContext) { pub struct AppState { pub languages: Arc, - pub client: Arc, - pub user_store: ModelHandle, + pub client: Arc, + pub user_store: ModelHandle, + pub channel_store: ModelHandle, pub fs: Arc, pub build_window_options: fn(Option, Option, &dyn Platform) -> WindowOptions<'static>, @@ -424,6 +425,8 @@ impl AppState { let http_client = util::http::FakeHttpClient::with_404_response(); let client = Client::new(http_client.clone(), cx); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); + let channel_store = + cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx)); theme::init((), cx); client::init(&client, cx); @@ -434,6 +437,7 @@ impl AppState { fs, languages, user_store, + channel_store, initialize_workspace: |_, _, _, _| Task::ready(Ok(())), build_window_options: |_, _, _| Default::default(), background_actions: || &[], @@ -3406,10 +3410,15 @@ impl Workspace { #[cfg(any(test, feature = "test-support"))] pub fn test_new(project: ModelHandle, cx: &mut ViewContext) -> Self { + let client = project.read(cx).client(); + let user_store = project.read(cx).user_store(); + let channel_store = + cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx)); let app_state = Arc::new(AppState { languages: project.read(cx).languages().clone(), - client: project.read(cx).client(), - user_store: project.read(cx).user_store(), + client, + user_store, + channel_store, fs: project.read(cx).fs().clone(), build_window_options: |_, _, _| Default::default(), initialize_workspace: |_, _, _, _| Task::ready(Ok(())), diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e44ab3e33acbb213f69b230a80075aea07890c85..34c1232712292078bdaad94ebd29bc42616a0bb4 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -7,7 +7,9 @@ use cli::{ ipc::{self, IpcSender}, CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME, }; -use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN}; +use client::{ + self, ChannelStore, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN, +}; use db::kvp::KEY_VALUE_STORE; use editor::{scroll::autoscroll::Autoscroll, Editor}; use futures::{ @@ -140,6 +142,8 @@ fn main() { languages::init(languages.clone(), node_runtime.clone()); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx)); + let channel_store = + cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx)); cx.set_global(client.clone()); @@ -181,6 +185,7 @@ fn main() { languages, client: client.clone(), user_store, + channel_store, fs, build_window_options, initialize_workspace, From 7434d66fdd84ae250e973135f7ce946d1255d362 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Aug 2023 13:22:06 -0700 Subject: [PATCH 020/105] WIP: Add channel creation to panel UI --- crates/client/src/channel_store.rs | 1 + crates/collab/src/db.rs | 38 +++++++ crates/collab/src/db/tests.rs | 90 ++++++++++++++++ crates/collab/src/rpc.rs | 34 +++++- crates/collab_ui/src/panel.rs | 162 +++++++++++++++++++++-------- script/zed-with-local-servers | 2 +- 6 files changed, 278 insertions(+), 49 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 678e712c7d50cdf4817ec3a05b42570b84a633ec..dfdb5fe9ed0beb031e6233ebb52de89ea5b20564 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -33,6 +33,7 @@ impl ChannelStore { ) -> Self { let rpc_subscription = client.add_message_handler(cx.handle(), Self::handle_update_channels); + Self { channels: vec![], channel_invitations: vec![], diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index f87b68c1ec082b8612565e0ddc36b0e7f0ab70a0..12e02b06edad06cede32d050ba65128bfe8951f8 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3214,6 +3214,44 @@ impl Database { .await } + pub async fn get_channel_invites(&self, user_id: UserId) -> Result> { + self.transaction(|tx| async move { + let tx = tx; + + let channel_invites = channel_member::Entity::find() + .filter( + channel_member::Column::UserId + .eq(user_id) + .and(channel_member::Column::Accepted.eq(false)), + ) + .all(&*tx) + .await?; + + let channels = channel::Entity::find() + .filter( + channel::Column::Id.is_in( + channel_invites + .into_iter() + .map(|channel_member| channel_member.channel_id), + ), + ) + .all(&*tx) + .await?; + + let channels = channels + .into_iter() + .map(|channel| Channel { + id: channel.id, + name: channel.name, + parent_id: None, + }) + .collect(); + + Ok(channels) + }) + .await + } + pub async fn get_channels(&self, user_id: UserId) -> Result> { self.transaction(|tx| async move { let tx = tx; diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 719e8693d4f29efe36724eb4d4b657d986e246e6..64ab03e02d122f1cdb51ff27fb726ea60a30a818 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -1023,6 +1023,96 @@ test_both_dbs!( } ); +test_both_dbs!( + test_channel_invites_postgres, + test_channel_invites_sqlite, + db, + { + let owner_id = db.create_server("test").await.unwrap().0 as u32; + + let user_1 = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "user1".into(), + github_user_id: 5, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let user_2 = db + .create_user( + "user2@example.com", + false, + NewUserParams { + github_login: "user2".into(), + github_user_id: 6, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let user_3 = db + .create_user( + "user3@example.com", + false, + NewUserParams { + github_login: "user3".into(), + github_user_id: 7, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let channel_1_1 = db + .create_root_channel("channel_1", "1", user_1) + .await + .unwrap(); + + let channel_1_2 = db + .create_root_channel("channel_2", "2", user_1) + .await + .unwrap(); + + db.invite_channel_member(channel_1_1, user_2, user_1, false) + .await + .unwrap(); + db.invite_channel_member(channel_1_2, user_2, user_1, false) + .await + .unwrap(); + db.invite_channel_member(channel_1_1, user_3, user_1, false) + .await + .unwrap(); + + let user_2_invites = db + .get_channel_invites(user_2) // -> [channel_1_1, channel_1_2] + .await + .unwrap() + .into_iter() + .map(|channel| channel.id) + .collect::>(); + + assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]); + + let user_3_invites = db + .get_channel_invites(user_3) // -> [channel_1_1] + .await + .unwrap() + .into_iter() + .map(|channel| channel.id) + .collect::>(); + + assert_eq!(user_3_invites, &[channel_1_1]) + } +); + #[gpui::test] async fn test_multiple_signup_overwrite() { let test_db = TestDb::postgres(build_background_executor()); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 0abf2c44a7b4f843287625cda415730402a1d00e..6461f67c388d456a630dcda991bdc2815ec5e592 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -516,15 +516,19 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, invite_code) = future::try_join( + let (contacts, invite_code, channels, channel_invites) = future::try_join4( this.app_state.db.get_contacts(user_id), - this.app_state.db.get_invite_code_for_user(user_id) + this.app_state.db.get_invite_code_for_user(user_id), + this.app_state.db.get_channels(user_id), + this.app_state.db.get_channel_invites(user_id) ).await?; { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; + this.peer.send(connection_id, build_initial_channels_update(channels, channel_invites))?; + if let Some((code, count)) = invite_code { this.peer.send(connection_id, proto::UpdateInviteInfo { @@ -2097,6 +2101,7 @@ async fn create_channel( response: Response, session: Session, ) -> Result<()> { + dbg!(&request); let db = session.db().await; let live_kit_room = format!("channel-{}", nanoid::nanoid!(30)); @@ -2307,6 +2312,31 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage { } } +fn build_initial_channels_update( + channels: Vec, + channel_invites: Vec, +) -> proto::UpdateChannels { + let mut update = proto::UpdateChannels::default(); + + for channel in channels { + update.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + }); + } + + for channel in channel_invites { + update.channel_invitations.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + }); + } + + update +} + fn build_initial_contacts_update( contacts: Vec, pool: &ConnectionPool, diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bdd01e42998beadf459ca66888e125fc28cb6366..bfaa414a27470768b4e571b96c8beab3e5fedc86 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -32,11 +32,10 @@ use theme::IconButton; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, + item::ItemHandle, Workspace, }; -use self::channel_modal::ChannelModal; - actions!(collab_panel, [ToggleFocus]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; @@ -52,6 +51,11 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::confirm); } +#[derive(Debug, Default)] +pub struct ChannelEditingState { + root_channel: bool, +} + pub struct CollabPanel { width: Option, fs: Arc, @@ -59,6 +63,8 @@ pub struct CollabPanel { pending_serialization: Task>, context_menu: ViewHandle, filter_editor: ViewHandle, + channel_name_editor: ViewHandle, + channel_editing_state: Option, entries: Vec, selection: Option, user_store: ModelHandle, @@ -93,7 +99,7 @@ enum Section { Offline, } -#[derive(Clone)] +#[derive(Clone, Debug)] enum ContactEntry { Header(Section, usize), CallParticipant { @@ -157,6 +163,23 @@ impl CollabPanel { }) .detach(); + let channel_name_editor = cx.add_view(|cx| { + Editor::single_line( + Some(Arc::new(|theme| { + theme.collab_panel.user_query_editor.clone() + })), + cx, + ) + }); + + cx.subscribe(&channel_name_editor, |this, _, event, cx| { + if let editor::Event::Blurred = event { + this.take_editing_state(cx); + cx.notify(); + } + }) + .detach(); + let list_state = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { let theme = theme::current(cx).clone(); @@ -166,7 +189,7 @@ impl CollabPanel { match &this.entries[ix] { ContactEntry::Header(section, depth) => { let is_collapsed = this.collapsed_sections.contains(section); - Self::render_header( + this.render_header( *section, &theme, *depth, @@ -250,8 +273,10 @@ impl CollabPanel { fs: workspace.app_state().fs.clone(), pending_serialization: Task::ready(None), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), + channel_name_editor, filter_editor, entries: Vec::default(), + channel_editing_state: None, selection: None, user_store: workspace.user_store().clone(), channel_store: workspace.app_state().channel_store.clone(), @@ -333,6 +358,13 @@ impl CollabPanel { ); } + fn is_editing_root_channel(&self) -> bool { + self.channel_editing_state + .as_ref() + .map(|state| state.root_channel) + .unwrap_or(false) + } + fn update_entries(&mut self, cx: &mut ViewContext) { let channel_store = self.channel_store.read(cx); let user_store = self.user_store.read(cx); @@ -944,7 +976,23 @@ impl CollabPanel { .into_any() } + fn take_editing_state( + &mut self, + cx: &mut ViewContext, + ) -> Option<(ChannelEditingState, String)> { + let result = self + .channel_editing_state + .take() + .map(|state| (state, self.channel_name_editor.read(cx).text(cx))); + + self.channel_name_editor + .update(cx, |editor, cx| editor.set_text("", cx)); + + result + } + fn render_header( + &self, section: Section, theme: &theme::Theme, depth: usize, @@ -1014,7 +1062,13 @@ impl CollabPanel { }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, |_, this, cx| { - this.toggle_channel_finder(cx); + if this.channel_editing_state.is_none() { + this.channel_editing_state = + Some(ChannelEditingState { root_channel: true }); + } + + cx.focus(this.channel_name_editor.as_any()); + cx.notify(); }) .with_tooltip::( 0, @@ -1027,6 +1081,13 @@ impl CollabPanel { _ => None, }; + let addition = match section { + Section::Channels if self.is_editing_root_channel() => { + Some(ChildView::new(self.channel_name_editor.as_any(), cx)) + } + _ => None, + }; + let can_collapse = depth > 0; let icon_size = (&theme.collab_panel).section_icon_size; MouseEventHandler::::new(section as usize, cx, |state, _| { @@ -1040,40 +1101,44 @@ impl CollabPanel { &theme.collab_panel.header_row }; - Flex::row() - .with_children(if can_collapse { - Some( - Svg::new(if is_collapsed { - "icons/chevron_right_8.svg" + Flex::column() + .with_child( + Flex::row() + .with_children(if can_collapse { + Some( + Svg::new(if is_collapsed { + "icons/chevron_right_8.svg" + } else { + "icons/chevron_down_8.svg" + }) + .with_color(header_style.text.color) + .constrained() + .with_max_width(icon_size) + .with_max_height(icon_size) + .aligned() + .constrained() + .with_width(icon_size) + .contained() + .with_margin_right( + theme.collab_panel.contact_username.container.margin.left, + ), + ) } else { - "icons/chevron_down_8.svg" + None }) - .with_color(header_style.text.color) - .constrained() - .with_max_width(icon_size) - .with_max_height(icon_size) - .aligned() + .with_child( + Label::new(text, header_style.text.clone()) + .aligned() + .left() + .flex(1., true), + ) + .with_children(button.map(|button| button.aligned().right())) .constrained() - .with_width(icon_size) + .with_height(theme.collab_panel.row_height) .contained() - .with_margin_right( - theme.collab_panel.contact_username.container.margin.left, - ), - ) - } else { - None - }) - .with_child( - Label::new(text, header_style.text.clone()) - .aligned() - .left() - .flex(1., true), + .with_style(header_style.container), ) - .with_children(button.map(|button| button.aligned().right())) - .constrained() - .with_height(theme.collab_panel.row_height) - .contained() - .with_style(header_style.container) + .with_children(addition) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, this, cx| { @@ -1189,7 +1254,7 @@ impl CollabPanel { cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; - MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { + MouseEventHandler::::new(channel.id as usize, cx, |state, _cx| { Flex::row() .with_child({ Svg::new("icons/hash") @@ -1218,7 +1283,7 @@ impl CollabPanel { fn render_channel_invite( channel: Arc, - user_store: ModelHandle, + channel_store: ModelHandle, theme: &theme::CollabPanel, is_selected: bool, cx: &mut ViewContext, @@ -1227,7 +1292,7 @@ impl CollabPanel { enum Accept {} let channel_id = channel.id; - let is_invite_pending = user_store.read(cx).is_channel_invite_pending(&channel); + let is_invite_pending = channel_store.read(cx).is_channel_invite_pending(&channel); let button_spacing = theme.contact_button_spacing; Flex::row() @@ -1401,7 +1466,7 @@ impl CollabPanel { } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - let did_clear = self.filter_editor.update(cx, |editor, cx| { + let mut did_clear = self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx) > 0 { editor.set_text("", cx); true @@ -1410,6 +1475,8 @@ impl CollabPanel { } }); + did_clear |= self.take_editing_state(cx).is_some(); + if !did_clear { cx.emit(Event::Dismissed); } @@ -1496,6 +1563,17 @@ impl CollabPanel { _ => {} } } + } else if let Some((_editing_state, channel_name)) = self.take_editing_state(cx) { + dbg!(&channel_name); + let create_channel = self.channel_store.update(cx, |channel_store, cx| { + channel_store.create_channel(&channel_name, None) + }); + + cx.foreground() + .spawn(async move { + dbg!(create_channel.await).ok(); + }) + .detach(); } } @@ -1522,14 +1600,6 @@ impl CollabPanel { } } - fn toggle_channel_finder(&mut self, cx: &mut ViewContext) { - if let Some(workspace) = self.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |_, cx| cx.add_view(|cx| ChannelModal::new(cx))); - }); - } - } - fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext) { let user_store = self.user_store.clone(); let prompt_message = format!( diff --git a/script/zed-with-local-servers b/script/zed-with-local-servers index f1de38adcf66f45e6a4ac3e4bce65579d1c9cfa0..c47b0e3de0126318ab6c0556582357cd700c2f17 100755 --- a/script/zed-with-local-servers +++ b/script/zed-with-local-servers @@ -1,3 +1,3 @@ #!/bin/bash -ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:3000 cargo run $@ +ZED_ADMIN_API_TOKEN=secret ZED_IMPERSONATE=as-cii ZED_SERVER_URL=http://localhost:8080 cargo run $@ From 56d4d5d1a8c8fc42cb678f8b618e47364049760f Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Aug 2023 13:33:31 -0700 Subject: [PATCH 021/105] Add root channel UI co-authored-by: Max --- crates/collab_ui/src/panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bfaa414a27470768b4e571b96c8beab3e5fedc86..53f7eee79a0cd0f52b2ef4aff93bed429769d9e6 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -1257,7 +1257,7 @@ impl CollabPanel { MouseEventHandler::::new(channel.id as usize, cx, |state, _cx| { Flex::row() .with_child({ - Svg::new("icons/hash") + Svg::new("icons/file_icons/hash.svg") // .with_style(theme.contact_avatar) .aligned() .left() @@ -1297,7 +1297,7 @@ impl CollabPanel { Flex::row() .with_child({ - Svg::new("icons/hash") + Svg::new("icons/file_icons/hash.svg") // .with_style(theme.contact_avatar) .aligned() .left() From 74437b3988626aeb7cfef8d297aabe03a16d4a48 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Aug 2023 16:06:21 -0700 Subject: [PATCH 022/105] Add remove channel method Move test client fields into appstate and fix tests Co-authored-by: max --- crates/client/src/channel_store.rs | 12 + crates/client/src/client.rs | 5 +- crates/collab/src/db.rs | 194 +++++++++++---- crates/collab/src/db/tests.rs | 24 ++ crates/collab/src/rpc.rs | 42 +++- crates/collab/src/tests.rs | 106 ++++---- crates/collab/src/tests/channel_tests.rs | 31 ++- crates/collab/src/tests/integration_tests.rs | 232 +++++++++--------- .../src/tests/randomized_integration_tests.rs | 66 ++--- crates/collab_ui/src/collab_ui.rs | 2 +- crates/collab_ui/src/panel.rs | 59 ++++- crates/collab_ui/src/panel/channel_modal.rs | 8 +- crates/rpc/proto/zed.proto | 6 + crates/rpc/src/proto.rs | 2 + crates/workspace/src/workspace.rs | 1 + 15 files changed, 534 insertions(+), 256 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index dfdb5fe9ed0beb031e6233ebb52de89ea5b20564..99501bbd2a119c31d21c95c0dae2d0b6e4230e08 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -51,6 +51,10 @@ impl ChannelStore { &self.channel_invitations } + pub fn channel_for_id(&self, channel_id: u64) -> Option> { + self.channels.iter().find(|c| c.id == channel_id).cloned() + } + pub fn create_channel( &self, name: &str, @@ -103,6 +107,14 @@ impl ChannelStore { false } + pub fn remove_channel(&self, channel_id: u64) -> impl Future> { + let client = self.client.clone(); + async move { + client.request(proto::RemoveChannel { channel_id }).await?; + Ok(()) + } + } + pub fn remove_member( &self, channel_id: u64, diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index a48b2849ae18c737a6264ff5be187b4010dc262f..1e86cef4cca81c882d64475c406c1d76db3a6417 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -575,7 +575,10 @@ impl Client { }), ); if prev_handler.is_some() { - panic!("registered handler for the same message twice"); + panic!( + "registered handler for the same message {} twice", + std::any::type_name::() + ); } Subscription::Message { diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 12e02b06edad06cede32d050ba65128bfe8951f8..066c93ec718f15a451164270dcaa0eb8b53dcd82 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -44,6 +44,7 @@ use serde::{Deserialize, Serialize}; pub use signup::{Invite, NewSignup, WaitlistSummary}; use sqlx::migrate::{Migrate, Migration, MigrationSource}; use sqlx::Connection; +use std::fmt::Write as _; use std::ops::{Deref, DerefMut}; use std::path::Path; use std::time::Duration; @@ -3131,6 +3132,74 @@ impl Database { .await } + pub async fn remove_channel( + &self, + channel_id: ChannelId, + user_id: UserId, + ) -> Result<(Vec, Vec)> { + self.transaction(move |tx| async move { + let tx = tx; + + // Check if user is an admin + channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(user_id)) + .and(channel_member::Column::Admin.eq(true)), + ) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("user is not allowed to remove this channel"))?; + + let mut descendants = self.get_channel_descendants([channel_id], &*tx).await?; + + // Keep channels which have another active + let mut channels_to_keep = channel_parent::Entity::find() + .filter( + channel_parent::Column::ChildId + .is_in(descendants.keys().copied().filter(|&id| id != channel_id)) + .and( + channel_parent::Column::ParentId.is_not_in(descendants.keys().copied()), + ), + ) + .stream(&*tx) + .await?; + + while let Some(row) = channels_to_keep.next().await { + let row = row?; + descendants.remove(&row.child_id); + } + + drop(channels_to_keep); + + let channels_to_remove = descendants.keys().copied().collect::>(); + + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryUserIds { + UserId, + } + + let members_to_notify: Vec = channel_member::Entity::find() + .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.iter().copied())) + .select_only() + .column(channel_member::Column::UserId) + .distinct() + .into_values::<_, QueryUserIds>() + .all(&*tx) + .await?; + + // Channel members and parents should delete via cascade + channel::Entity::delete_many() + .filter(channel::Column::Id.is_in(channels_to_remove.iter().copied())) + .exec(&*tx) + .await?; + + Ok((channels_to_remove, members_to_notify)) + }) + .await + } + pub async fn invite_channel_member( &self, channel_id: ChannelId, @@ -3256,50 +3325,32 @@ impl Database { self.transaction(|tx| async move { let tx = tx; - // Breadth first list of all edges in this user's channels - let sql = r#" - WITH RECURSIVE channel_tree(child_id, parent_id, depth) AS ( - SELECT channel_id as child_id, CAST(NULL as INTEGER) as parent_id, 0 - FROM channel_members - WHERE user_id = $1 AND accepted - UNION - SELECT channel_parents.child_id, channel_parents.parent_id, channel_tree.depth + 1 - FROM channel_parents, channel_tree - WHERE channel_parents.parent_id = channel_tree.child_id - ) - SELECT channel_tree.child_id, channel_tree.parent_id - FROM channel_tree - ORDER BY child_id, parent_id IS NOT NULL - "#; - - #[derive(FromQueryResult, Debug, PartialEq)] - pub struct ChannelParent { - pub child_id: ChannelId, - pub parent_id: Option, + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryChannelIds { + ChannelId, } - let stmt = Statement::from_sql_and_values( - self.pool.get_database_backend(), - sql, - vec![user_id.into()], - ); - - let mut parents_by_child_id = HashMap::default(); - let mut parents = channel_parent::Entity::find() - .from_raw_sql(stmt) - .into_model::() - .stream(&*tx).await?; - while let Some(parent) = parents.next().await { - let parent = parent?; - parents_by_child_id.insert(parent.child_id, parent.parent_id); - } + let starting_channel_ids: Vec = channel_member::Entity::find() + .filter( + channel_member::Column::UserId + .eq(user_id) + .and(channel_member::Column::Accepted.eq(true)), + ) + .select_only() + .column(channel_member::Column::ChannelId) + .into_values::<_, QueryChannelIds>() + .all(&*tx) + .await?; - drop(parents); + let parents_by_child_id = self + .get_channel_descendants(starting_channel_ids, &*tx) + .await?; let mut channels = Vec::with_capacity(parents_by_child_id.len()); let mut rows = channel::Entity::find() .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied())) - .stream(&*tx).await?; + .stream(&*tx) + .await?; while let Some(row) = rows.next().await { let row = row?; @@ -3317,18 +3368,73 @@ impl Database { .await } - pub async fn get_channel(&self, channel_id: ChannelId) -> Result { + async fn get_channel_descendants( + &self, + channel_ids: impl IntoIterator, + tx: &DatabaseTransaction, + ) -> Result>> { + let mut values = String::new(); + for id in channel_ids { + if !values.is_empty() { + values.push_str(", "); + } + write!(&mut values, "({})", id).unwrap(); + } + + if values.is_empty() { + return Ok(HashMap::default()); + } + + let sql = format!( + r#" + WITH RECURSIVE channel_tree(child_id, parent_id) AS ( + SELECT root_ids.column1 as child_id, CAST(NULL as INTEGER) as parent_id + FROM (VALUES {}) as root_ids + UNION + SELECT channel_parents.child_id, channel_parents.parent_id + FROM channel_parents, channel_tree + WHERE channel_parents.parent_id = channel_tree.child_id + ) + SELECT channel_tree.child_id, channel_tree.parent_id + FROM channel_tree + ORDER BY child_id, parent_id IS NOT NULL + "#, + values + ); + + #[derive(FromQueryResult, Debug, PartialEq)] + pub struct ChannelParent { + pub child_id: ChannelId, + pub parent_id: Option, + } + + let stmt = Statement::from_string(self.pool.get_database_backend(), sql); + + let mut parents_by_child_id = HashMap::default(); + let mut parents = channel_parent::Entity::find() + .from_raw_sql(stmt) + .into_model::() + .stream(tx) + .await?; + + while let Some(parent) = parents.next().await { + let parent = parent?; + parents_by_child_id.insert(parent.child_id, parent.parent_id); + } + + Ok(parents_by_child_id) + } + + pub async fn get_channel(&self, channel_id: ChannelId) -> Result> { self.transaction(|tx| async move { let tx = tx; - let channel = channel::Entity::find_by_id(channel_id) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("no such channel"))?; - Ok(Channel { + let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?; + + Ok(channel.map(|channel| Channel { id: channel.id, name: channel.name, parent_id: None, - }) + })) }) .await } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 64ab03e02d122f1cdb51ff27fb726ea60a30a818..3a47097f7d91f721c2fdd0da54b854c543a97e77 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -918,6 +918,11 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .await .unwrap(); + let cargo_ra_id = db + .create_channel("cargo-ra", Some(cargo_id), "7", a_id) + .await + .unwrap(); + let channels = db.get_channels(a_id).await.unwrap(); assert_eq!( @@ -952,9 +957,28 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { id: cargo_id, name: "cargo".to_string(), parent_id: Some(rust_id), + }, + Channel { + id: cargo_ra_id, + name: "cargo-ra".to_string(), + parent_id: Some(cargo_id), } ] ); + + // Remove a single channel + db.remove_channel(crdb_id, a_id).await.unwrap(); + assert!(db.get_channel(crdb_id).await.unwrap().is_none()); + + // Remove a channel tree + let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap(); + channel_ids.sort(); + assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]); + assert_eq!(user_ids, &[a_id]); + + assert!(db.get_channel(rust_id).await.unwrap().is_none()); + assert!(db.get_channel(cargo_id).await.unwrap().is_none()); + assert!(db.get_channel(cargo_ra_id).await.unwrap().is_none()); }); test_both_dbs!( diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 6461f67c388d456a630dcda991bdc2815ec5e592..1465c666016f39f6b5de2f1dcbf4def1dbb2eaaf 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -243,6 +243,7 @@ impl Server { .add_request_handler(remove_contact) .add_request_handler(respond_to_contact_request) .add_request_handler(create_channel) + .add_request_handler(remove_channel) .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) .add_request_handler(respond_to_channel_invite) @@ -529,7 +530,6 @@ impl Server { this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_initial_channels_update(channels, channel_invites))?; - if let Some((code, count)) = invite_code { this.peer.send(connection_id, proto::UpdateInviteInfo { url: format!("{}{}", this.app_state.config.invite_link_prefix, code), @@ -2101,7 +2101,6 @@ async fn create_channel( response: Response, session: Session, ) -> Result<()> { - dbg!(&request); let db = session.db().await; let live_kit_room = format!("channel-{}", nanoid::nanoid!(30)); @@ -2132,6 +2131,35 @@ async fn create_channel( Ok(()) } +async fn remove_channel( + request: proto::RemoveChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + + let channel_id = request.channel_id; + let (removed_channels, member_ids) = db + .remove_channel(ChannelId::from_proto(channel_id), session.user_id) + .await?; + response.send(proto::Ack {})?; + + // Notify members of removed channels + let mut update = proto::UpdateChannels::default(); + update + .remove_channels + .extend(removed_channels.into_iter().map(|id| id.to_proto())); + + let connection_pool = session.connection_pool().await; + for member_id in member_ids { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + + Ok(()) +} + async fn invite_channel_member( request: proto::InviteChannelMember, response: Response, @@ -2139,7 +2167,10 @@ async fn invite_channel_member( ) -> Result<()> { let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - let channel = db.get_channel(channel_id).await?; + let channel = db + .get_channel(channel_id) + .await? + .ok_or_else(|| anyhow!("channel not found"))?; let invitee_id = UserId::from_proto(request.user_id); db.invite_channel_member(channel_id, invitee_id, session.user_id, false) .await?; @@ -2177,7 +2208,10 @@ async fn respond_to_channel_invite( ) -> Result<()> { let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - let channel = db.get_channel(channel_id).await?; + let channel = db + .get_channel(channel_id) + .await? + .ok_or_else(|| anyhow!("no such channel"))?; db.respond_to_channel_invite(channel_id, session.user_id, request.accept) .await?; diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 98ad2afb8af0dfe5571cb4029e1cf4c352ce24db..e0346dbe7f289feec118aeebf09b12dbeeea786e 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -14,8 +14,8 @@ use collections::{HashMap, HashSet}; use fs::FakeFs; use futures::{channel::oneshot, StreamExt as _}; use gpui::{ - elements::*, executor::Deterministic, AnyElement, Entity, ModelHandle, TestAppContext, View, - ViewContext, ViewHandle, WeakViewHandle, + elements::*, executor::Deterministic, AnyElement, Entity, ModelHandle, Task, TestAppContext, + View, ViewContext, ViewHandle, WeakViewHandle, }; use language::LanguageRegistry; use parking_lot::Mutex; @@ -197,7 +197,7 @@ impl TestServer { languages: Arc::new(LanguageRegistry::test()), fs: fs.clone(), build_window_options: |_, _, _| Default::default(), - initialize_workspace: |_, _, _, _| unimplemented!(), + initialize_workspace: |_, _, _, _| Task::ready(Ok(())), background_actions: || &[], }); @@ -218,13 +218,9 @@ impl TestServer { .unwrap(); let client = TestClient { - client, + app_state, username: name.to_string(), state: Default::default(), - user_store, - channel_store, - fs, - language_registry: Arc::new(LanguageRegistry::test()), }; client.wait_for_current_user(cx).await; client @@ -252,6 +248,7 @@ impl TestServer { let (client_a, cx_a) = left.last_mut().unwrap(); for (client_b, cx_b) in right { client_a + .app_state .user_store .update(*cx_a, |store, cx| { store.request_contact(client_b.user_id().unwrap(), cx) @@ -260,6 +257,7 @@ impl TestServer { .unwrap(); cx_a.foreground().run_until_parked(); client_b + .app_state .user_store .update(*cx_b, |store, cx| { store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) @@ -278,6 +276,7 @@ impl TestServer { ) -> u64 { let (admin_client, admin_cx) = admin; let channel_id = admin_client + .app_state .channel_store .update(admin_cx, |channel_store, _| { channel_store.create_channel(channel, None) @@ -287,6 +286,7 @@ impl TestServer { for (member_client, member_cx) in members { admin_client + .app_state .channel_store .update(admin_cx, |channel_store, _| { channel_store.invite_member(channel_id, member_client.user_id().unwrap(), false) @@ -297,6 +297,7 @@ impl TestServer { admin_cx.foreground().run_until_parked(); member_client + .app_state .channel_store .update(*member_cx, |channels, _| { channels.respond_to_channel_invite(channel_id, true) @@ -359,13 +360,9 @@ impl Drop for TestServer { } struct TestClient { - client: Arc, username: String, state: RefCell, - pub user_store: ModelHandle, - pub channel_store: ModelHandle, - language_registry: Arc, - fs: Arc, + app_state: Arc, } #[derive(Default)] @@ -379,7 +376,7 @@ impl Deref for TestClient { type Target = Arc; fn deref(&self) -> &Self::Target { - &self.client + &self.app_state.client } } @@ -390,22 +387,45 @@ struct ContactsSummary { } impl TestClient { + pub fn fs(&self) -> &FakeFs { + self.app_state.fs.as_fake() + } + + pub fn channel_store(&self) -> &ModelHandle { + &self.app_state.channel_store + } + + pub fn user_store(&self) -> &ModelHandle { + &self.app_state.user_store + } + + pub fn language_registry(&self) -> &Arc { + &self.app_state.languages + } + + pub fn client(&self) -> &Arc { + &self.app_state.client + } + pub fn current_user_id(&self, cx: &TestAppContext) -> UserId { UserId::from_proto( - self.user_store + self.app_state + .user_store .read_with(cx, |user_store, _| user_store.current_user().unwrap().id), ) } async fn wait_for_current_user(&self, cx: &TestAppContext) { let mut authed_user = self + .app_state .user_store .read_with(cx, |user_store, _| user_store.watch_current_user()); while authed_user.next().await.unwrap().is_none() {} } async fn clear_contacts(&self, cx: &mut TestAppContext) { - self.user_store + self.app_state + .user_store .update(cx, |store, _| store.clear_contacts()) .await; } @@ -443,23 +463,25 @@ impl TestClient { } fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary { - self.user_store.read_with(cx, |store, _| ContactsSummary { - current: store - .contacts() - .iter() - .map(|contact| contact.user.github_login.clone()) - .collect(), - outgoing_requests: store - .outgoing_contact_requests() - .iter() - .map(|user| user.github_login.clone()) - .collect(), - incoming_requests: store - .incoming_contact_requests() - .iter() - .map(|user| user.github_login.clone()) - .collect(), - }) + self.app_state + .user_store + .read_with(cx, |store, _| ContactsSummary { + current: store + .contacts() + .iter() + .map(|contact| contact.user.github_login.clone()) + .collect(), + outgoing_requests: store + .outgoing_contact_requests() + .iter() + .map(|user| user.github_login.clone()) + .collect(), + incoming_requests: store + .incoming_contact_requests() + .iter() + .map(|user| user.github_login.clone()) + .collect(), + }) } async fn build_local_project( @@ -469,10 +491,10 @@ impl TestClient { ) -> (ModelHandle, WorktreeId) { let project = cx.update(|cx| { Project::local( - self.client.clone(), - self.user_store.clone(), - self.language_registry.clone(), - self.fs.clone(), + self.client().clone(), + self.app_state.user_store.clone(), + self.app_state.languages.clone(), + self.app_state.fs.clone(), cx, ) }); @@ -498,8 +520,8 @@ impl TestClient { room.update(guest_cx, |room, cx| { room.join_project( host_project_id, - self.language_registry.clone(), - self.fs.clone(), + self.app_state.languages.clone(), + self.app_state.fs.clone(), cx, ) }) @@ -541,7 +563,9 @@ impl TestClient { // We use a workspace container so that we don't need to remove the window in order to // drop the workspace and we can use a ViewHandle instead. let (window_id, container) = cx.add_window(|_| WorkspaceContainer { workspace: None }); - let workspace = cx.add_view(window_id, |cx| Workspace::test_new(project.clone(), cx)); + let workspace = cx.add_view(window_id, |cx| { + Workspace::new(0, project.clone(), self.app_state.clone(), cx) + }); container.update(cx, |container, cx| { container.workspace = Some(workspace.downgrade()); cx.notify(); @@ -552,7 +576,7 @@ impl TestClient { impl Drop for TestClient { fn drop(&mut self) { - self.client.teardown(); + self.app_state.client.teardown(); } } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index ffd517f52ac4a926bc58d1eb965d254b02bb7745..14363b74cfa4fbcab64b27c00c51cc142301a405 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -19,14 +19,14 @@ async fn test_basic_channels( let client_b = server.create_client(cx_b, "user_b").await; let channel_a_id = client_a - .channel_store + .channel_store() .update(cx_a, |channel_store, _| { channel_store.create_channel("channel-a", None) }) .await .unwrap(); - client_a.channel_store.read_with(cx_a, |channels, _| { + client_a.channel_store().read_with(cx_a, |channels, _| { assert_eq!( channels.channels(), &[Arc::new(Channel { @@ -39,12 +39,12 @@ async fn test_basic_channels( }); client_b - .channel_store + .channel_store() .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); // Invite client B to channel A as client A. client_a - .channel_store + .channel_store() .update(cx_a, |channel_store, _| { channel_store.invite_member(channel_a_id, client_b.user_id().unwrap(), false) }) @@ -54,7 +54,7 @@ async fn test_basic_channels( // Wait for client b to see the invitation deterministic.run_until_parked(); - client_b.channel_store.read_with(cx_b, |channels, _| { + client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!( channels.channel_invitations(), &[Arc::new(Channel { @@ -68,13 +68,13 @@ async fn test_basic_channels( // Client B now sees that they are in channel A. client_b - .channel_store + .channel_store() .update(cx_b, |channels, _| { channels.respond_to_channel_invite(channel_a_id, true) }) .await .unwrap(); - client_b.channel_store.read_with(cx_b, |channels, _| { + client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!(channels.channel_invitations(), &[]); assert_eq!( channels.channels(), @@ -86,6 +86,23 @@ async fn test_basic_channels( })] ) }); + + // Client A deletes the channel + client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.remove_channel(channel_a_id) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + client_a + .channel_store() + .read_with(cx_a, |channels, _| assert_eq!(channels.channels(), &[])); + client_b + .channel_store() + .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); } #[gpui::test] diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 5a27787dbca7f83b39a2af50976c23c56fa4c995..93ebb812ad567bff4c3834c892f5777708774903 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -749,7 +749,7 @@ async fn test_server_restarts( let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; client_a - .fs + .fs() .insert_tree("/a", json!({ "a.txt": "a-contents" })) .await; @@ -1221,7 +1221,7 @@ async fn test_share_project( let active_call_c = cx_c.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -1388,7 +1388,7 @@ async fn test_unshare_project( let active_call_b = cx_b.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -1477,7 +1477,7 @@ async fn test_host_disconnect( cx_b.update(editor::init); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -1500,7 +1500,7 @@ async fn test_host_disconnect( assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); let (window_id_b, workspace_b) = - cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); + cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "b.txt"), None, true, cx) @@ -1584,7 +1584,7 @@ async fn test_project_reconnect( cx_b.update(editor::init); client_a - .fs + .fs() .insert_tree( "/root-1", json!({ @@ -1612,7 +1612,7 @@ async fn test_project_reconnect( ) .await; client_a - .fs + .fs() .insert_tree( "/root-2", json!({ @@ -1621,7 +1621,7 @@ async fn test_project_reconnect( ) .await; client_a - .fs + .fs() .insert_tree( "/root-3", json!({ @@ -1701,7 +1701,7 @@ async fn test_project_reconnect( // While client A is disconnected, add and remove files from client A's project. client_a - .fs + .fs() .insert_tree( "/root-1/dir1/subdir2", json!({ @@ -1713,7 +1713,7 @@ async fn test_project_reconnect( ) .await; client_a - .fs + .fs() .remove_dir( "/root-1/dir1/subdir1".as_ref(), RemoveOptions { @@ -1835,11 +1835,11 @@ async fn test_project_reconnect( // While client B is disconnected, add and remove files from client A's project client_a - .fs + .fs() .insert_file("/root-1/dir1/subdir2/j.txt", "j-contents".into()) .await; client_a - .fs + .fs() .remove_file("/root-1/dir1/subdir2/i.txt".as_ref(), Default::default()) .await .unwrap(); @@ -1925,8 +1925,8 @@ async fn test_active_call_events( let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; - client_a.fs.insert_tree("/a", json!({})).await; - client_b.fs.insert_tree("/b", json!({})).await; + client_a.fs().insert_tree("/a", json!({})).await; + client_b.fs().insert_tree("/b", json!({})).await; let (project_a, _) = client_a.build_local_project("/a", cx_a).await; let (project_b, _) = client_b.build_local_project("/b", cx_b).await; @@ -2014,8 +2014,8 @@ async fn test_room_location( let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; - client_a.fs.insert_tree("/a", json!({})).await; - client_b.fs.insert_tree("/b", json!({})).await; + client_a.fs().insert_tree("/a", json!({})).await; + client_b.fs().insert_tree("/b", json!({})).await; let active_call_a = cx_a.read(ActiveCall::global); let active_call_b = cx_b.read(ActiveCall::global); @@ -2204,12 +2204,12 @@ async fn test_propagate_saves_and_fs_changes( Some(tree_sitter_rust::language()), )); for client in [&client_a, &client_b, &client_c] { - client.language_registry.add(rust.clone()); - client.language_registry.add(javascript.clone()); + client.language_registry().add(rust.clone()); + client.language_registry().add(javascript.clone()); } client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -2279,7 +2279,7 @@ async fn test_propagate_saves_and_fs_changes( buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx)); save_b.await.unwrap(); assert_eq!( - client_a.fs.load("/a/file1.rs".as_ref()).await.unwrap(), + client_a.fs().load("/a/file1.rs".as_ref()).await.unwrap(), "hi-a, i-am-c, i-am-b, i-am-a" ); @@ -2290,7 +2290,7 @@ async fn test_propagate_saves_and_fs_changes( // Make changes on host's file system, see those changes on guest worktrees. client_a - .fs + .fs() .rename( "/a/file1.rs".as_ref(), "/a/file1.js".as_ref(), @@ -2299,11 +2299,11 @@ async fn test_propagate_saves_and_fs_changes( .await .unwrap(); client_a - .fs + .fs() .rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default()) .await .unwrap(); - client_a.fs.insert_file("/a/file4", "4".into()).await; + client_a.fs().insert_file("/a/file4", "4".into()).await; deterministic.run_until_parked(); worktree_a.read_with(cx_a, |tree, _| { @@ -2397,7 +2397,7 @@ async fn test_git_diff_base_change( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -2441,7 +2441,7 @@ async fn test_git_diff_base_change( " .unindent(); - client_a.fs.as_fake().set_index_for_repo( + client_a.fs().set_index_for_repo( Path::new("/dir/.git"), &[(Path::new("a.txt"), diff_base.clone())], ); @@ -2486,7 +2486,7 @@ async fn test_git_diff_base_change( ); }); - client_a.fs.as_fake().set_index_for_repo( + client_a.fs().set_index_for_repo( Path::new("/dir/.git"), &[(Path::new("a.txt"), new_diff_base.clone())], ); @@ -2531,7 +2531,7 @@ async fn test_git_diff_base_change( " .unindent(); - client_a.fs.as_fake().set_index_for_repo( + client_a.fs().set_index_for_repo( Path::new("/dir/sub/.git"), &[(Path::new("b.txt"), diff_base.clone())], ); @@ -2576,7 +2576,7 @@ async fn test_git_diff_base_change( ); }); - client_a.fs.as_fake().set_index_for_repo( + client_a.fs().set_index_for_repo( Path::new("/dir/sub/.git"), &[(Path::new("b.txt"), new_diff_base.clone())], ); @@ -2635,7 +2635,7 @@ async fn test_git_branch_name( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -2654,8 +2654,7 @@ async fn test_git_branch_name( let project_remote = client_b.build_remote_project(project_id, cx_b).await; client_a - .fs - .as_fake() + .fs() .set_branch_name(Path::new("/dir/.git"), Some("branch-1")); // Wait for it to catch up to the new branch @@ -2680,8 +2679,7 @@ async fn test_git_branch_name( }); client_a - .fs - .as_fake() + .fs() .set_branch_name(Path::new("/dir/.git"), Some("branch-2")); // Wait for buffer_local_a to receive it @@ -2720,7 +2718,7 @@ async fn test_git_status_sync( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -2734,7 +2732,7 @@ async fn test_git_status_sync( const A_TXT: &'static str = "a.txt"; const B_TXT: &'static str = "b.txt"; - client_a.fs.as_fake().set_status_for_repo_via_git_operation( + client_a.fs().set_status_for_repo_via_git_operation( Path::new("/dir/.git"), &[ (&Path::new(A_TXT), GitFileStatus::Added), @@ -2780,16 +2778,13 @@ async fn test_git_status_sync( assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx); }); - client_a - .fs - .as_fake() - .set_status_for_repo_via_working_copy_change( - Path::new("/dir/.git"), - &[ - (&Path::new(A_TXT), GitFileStatus::Modified), - (&Path::new(B_TXT), GitFileStatus::Modified), - ], - ); + client_a.fs().set_status_for_repo_via_working_copy_change( + Path::new("/dir/.git"), + &[ + (&Path::new(A_TXT), GitFileStatus::Modified), + (&Path::new(B_TXT), GitFileStatus::Modified), + ], + ); // Wait for buffer_local_a to receive it deterministic.run_until_parked(); @@ -2860,7 +2855,7 @@ async fn test_fs_operations( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -3133,7 +3128,7 @@ async fn test_local_settings( // As client A, open a project that contains some local settings files client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -3175,7 +3170,7 @@ async fn test_local_settings( // As client A, update a settings file. As Client B, see the changed settings. client_a - .fs + .fs() .insert_file("/dir/.zed/settings.json", r#"{}"#.into()) .await; deterministic.run_until_parked(); @@ -3192,17 +3187,17 @@ async fn test_local_settings( // As client A, create and remove some settings files. As client B, see the changed settings. client_a - .fs + .fs() .remove_file("/dir/.zed/settings.json".as_ref(), Default::default()) .await .unwrap(); client_a - .fs + .fs() .create_dir("/dir/b/.zed".as_ref()) .await .unwrap(); client_a - .fs + .fs() .insert_file("/dir/b/.zed/settings.json", r#"{"tab_size": 4}"#.into()) .await; deterministic.run_until_parked(); @@ -3223,11 +3218,11 @@ async fn test_local_settings( // As client A, change and remove settings files while client B is disconnected. client_a - .fs + .fs() .insert_file("/dir/a/.zed/settings.json", r#"{"hard_tabs":true}"#.into()) .await; client_a - .fs + .fs() .remove_file("/dir/b/.zed/settings.json".as_ref(), Default::default()) .await .unwrap(); @@ -3261,7 +3256,7 @@ async fn test_buffer_conflict_after_save( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -3323,7 +3318,7 @@ async fn test_buffer_reloading( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -3351,7 +3346,7 @@ async fn test_buffer_reloading( let new_contents = Rope::from("d\ne\nf"); client_a - .fs + .fs() .save("/dir/a.txt".as_ref(), &new_contents, LineEnding::Windows) .await .unwrap(); @@ -3380,7 +3375,7 @@ async fn test_editing_while_guest_opens_buffer( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree("/dir", json!({ "a.txt": "a-contents" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; @@ -3429,7 +3424,7 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree("/dir", json!({ "a.txt": "Some text\n" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; @@ -3527,7 +3522,7 @@ async fn test_leaving_worktree_while_opening_buffer( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree("/dir", json!({ "a.txt": "a-contents" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await; @@ -3570,7 +3565,7 @@ async fn test_canceling_buffer_opening( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -3626,7 +3621,7 @@ async fn test_leaving_project( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -3714,9 +3709,9 @@ async fn test_leaving_project( cx_b.spawn(|cx| { Project::remote( project_id, - client_b.client.clone(), - client_b.user_store.clone(), - client_b.language_registry.clone(), + client_b.app_state.client.clone(), + client_b.user_store().clone(), + client_b.language_registry().clone(), FakeFs::new(cx.background()), cx, ) @@ -3768,11 +3763,11 @@ async fn test_collaborating_with_diagnostics( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); // Share a project as client A client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -4040,11 +4035,11 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( ..Default::default() })) .await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); let file_names = &["one.rs", "two.rs", "three.rs", "four.rs", "five.rs"]; client_a - .fs + .fs() .insert_tree( "/test", json!({ @@ -4181,10 +4176,10 @@ async fn test_collaborating_with_completion( ..Default::default() })) .await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -4342,7 +4337,7 @@ async fn test_reloading_buffer_manually( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree("/a", json!({ "a.rs": "let one = 1;" })) .await; let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; @@ -4373,7 +4368,7 @@ async fn test_reloading_buffer_manually( buffer_a.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "let six = 6;")); client_a - .fs + .fs() .save( "/a/a.rs".as_ref(), &Rope::from("let seven = 7;"), @@ -4444,14 +4439,14 @@ async fn test_formatting_buffer( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); // Here we insert a fake tree with a directory that exists on disk. This is needed // because later we'll invoke a command, which requires passing a working directory // that points to a valid location on disk. let directory = env::current_dir().unwrap(); client_a - .fs + .fs() .insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" })) .await; let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await; @@ -4553,10 +4548,10 @@ async fn test_definition( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/root", json!({ @@ -4701,10 +4696,10 @@ async fn test_references( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/root", json!({ @@ -4797,7 +4792,7 @@ async fn test_project_search( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/root", json!({ @@ -4883,7 +4878,7 @@ async fn test_document_highlights( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/root-1", json!({ @@ -4902,7 +4897,7 @@ async fn test_document_highlights( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await; let project_id = active_call_a @@ -4989,7 +4984,7 @@ async fn test_lsp_hover( let active_call_a = cx_a.read(ActiveCall::global); client_a - .fs + .fs() .insert_tree( "/root-1", json!({ @@ -5008,7 +5003,7 @@ async fn test_lsp_hover( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await; let project_id = active_call_a @@ -5107,10 +5102,10 @@ async fn test_project_symbols( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/code", json!({ @@ -5218,10 +5213,10 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/root", json!({ @@ -5278,6 +5273,7 @@ async fn test_collaborating_with_code_actions( deterministic.forbid_parking(); let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; + // let client_b = server.create_client(cx_b, "user_b").await; server .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) @@ -5296,10 +5292,10 @@ async fn test_collaborating_with_code_actions( Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -5316,7 +5312,8 @@ async fn test_collaborating_with_code_actions( // Join the project as client B. let project_b = client_b.build_remote_project(project_id, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); + let (_window_b, workspace_b) = + cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "main.rs"), None, true, cx) @@ -5521,10 +5518,10 @@ async fn test_collaborating_with_renames( ..Default::default() })) .await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -5540,7 +5537,8 @@ async fn test_collaborating_with_renames( .unwrap(); let project_b = client_b.build_remote_project(project_id, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); + let (_window_b, workspace_b) = + cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "one.rs"), None, true, cx) @@ -5706,10 +5704,10 @@ async fn test_language_server_statuses( ..Default::default() })) .await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/dir", json!({ @@ -6166,7 +6164,7 @@ async fn test_contacts( // Test removing a contact client_b - .user_store + .user_store() .update(cx_b, |store, cx| { store.remove_contact(client_c.user_id().unwrap(), cx) }) @@ -6189,7 +6187,7 @@ async fn test_contacts( client: &TestClient, cx: &TestAppContext, ) -> Vec<(String, &'static str, &'static str)> { - client.user_store.read_with(cx, |store, _| { + client.user_store().read_with(cx, |store, _| { store .contacts() .iter() @@ -6232,14 +6230,14 @@ async fn test_contact_requests( // User A and User C request that user B become their contact. client_a - .user_store + .user_store() .update(cx_a, |store, cx| { store.request_contact(client_b.user_id().unwrap(), cx) }) .await .unwrap(); client_c - .user_store + .user_store() .update(cx_c, |store, cx| { store.request_contact(client_b.user_id().unwrap(), cx) }) @@ -6293,7 +6291,7 @@ async fn test_contact_requests( // User B accepts the request from user A. client_b - .user_store + .user_store() .update(cx_b, |store, cx| { store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) }) @@ -6337,7 +6335,7 @@ async fn test_contact_requests( // User B rejects the request from user C. client_b - .user_store + .user_store() .update(cx_b, |store, cx| { store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx) }) @@ -6419,7 +6417,7 @@ async fn test_basic_following( cx_b.update(editor::init); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -6980,7 +6978,7 @@ async fn test_join_call_after_screen_was_shared( .await .unwrap(); - client_b.user_store.update(cx_b, |user_store, _| { + client_b.user_store().update(cx_b, |user_store, _| { user_store.clear_cache(); }); @@ -7040,7 +7038,7 @@ async fn test_following_tab_order( cx_b.update(editor::init); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7163,7 +7161,7 @@ async fn test_peers_following_each_other( // Client A shares a project. client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7336,7 +7334,7 @@ async fn test_auto_unfollowing( // Client A shares a project. client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7500,7 +7498,7 @@ async fn test_peers_simultaneously_following_each_other( cx_a.update(editor::init); cx_b.update(editor::init); - client_a.fs.insert_tree("/a", json!({})).await; + client_a.fs().insert_tree("/a", json!({})).await; let (project_a, _) = client_a.build_local_project("/a", cx_a).await; let workspace_a = client_a.build_workspace(&project_a, cx_a); let project_id = active_call_a @@ -7577,10 +7575,10 @@ async fn test_on_input_format_from_host_to_guest( ..Default::default() })) .await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7706,10 +7704,10 @@ async fn test_on_input_format_from_guest_to_host( ..Default::default() })) .await; - client_a.language_registry.add(Arc::new(language)); + client_a.language_registry().add(Arc::new(language)); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7862,11 +7860,11 @@ async fn test_mutual_editor_inlay_hint_cache_update( })) .await; let language = Arc::new(language); - client_a.language_registry.add(Arc::clone(&language)); - client_b.language_registry.add(language); + client_a.language_registry().add(Arc::clone(&language)); + client_b.language_registry().add(language); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -8169,11 +8167,11 @@ async fn test_inlay_hint_refresh_is_forwarded( })) .await; let language = Arc::new(language); - client_a.language_registry.add(Arc::clone(&language)); - client_b.language_registry.add(language); + client_a.language_registry().add(Arc::clone(&language)); + client_b.language_registry().add(language); client_a - .fs + .fs() .insert_tree( "/a", json!({ diff --git a/crates/collab/src/tests/randomized_integration_tests.rs b/crates/collab/src/tests/randomized_integration_tests.rs index 8062a12b83264f8ac4521f9071318d45fb5b0265..8202b53fdc0a352519ce5fb799b94ec3475fbc6b 100644 --- a/crates/collab/src/tests/randomized_integration_tests.rs +++ b/crates/collab/src/tests/randomized_integration_tests.rs @@ -396,9 +396,9 @@ async fn apply_client_operation( ); let root_path = Path::new("/").join(&first_root_name); - client.fs.create_dir(&root_path).await.unwrap(); + client.fs().create_dir(&root_path).await.unwrap(); client - .fs + .fs() .create_file(&root_path.join("main.rs"), Default::default()) .await .unwrap(); @@ -422,8 +422,8 @@ async fn apply_client_operation( ); ensure_project_shared(&project, client, cx).await; - if !client.fs.paths(false).contains(&new_root_path) { - client.fs.create_dir(&new_root_path).await.unwrap(); + if !client.fs().paths(false).contains(&new_root_path) { + client.fs().create_dir(&new_root_path).await.unwrap(); } project .update(cx, |project, cx| { @@ -475,7 +475,7 @@ async fn apply_client_operation( Some(room.update(cx, |room, cx| { room.join_project( project_id, - client.language_registry.clone(), + client.language_registry().clone(), FakeFs::new(cx.background().clone()), cx, ) @@ -743,7 +743,7 @@ async fn apply_client_operation( content, } => { if !client - .fs + .fs() .directories(false) .contains(&path.parent().unwrap().to_owned()) { @@ -752,14 +752,14 @@ async fn apply_client_operation( if is_dir { log::info!("{}: creating dir at {:?}", client.username, path); - client.fs.create_dir(&path).await.unwrap(); + client.fs().create_dir(&path).await.unwrap(); } else { - let exists = client.fs.metadata(&path).await?.is_some(); + let exists = client.fs().metadata(&path).await?.is_some(); let verb = if exists { "updating" } else { "creating" }; log::info!("{}: {} file at {:?}", verb, client.username, path); client - .fs + .fs() .save(&path, &content.as_str().into(), fs::LineEnding::Unix) .await .unwrap(); @@ -771,12 +771,12 @@ async fn apply_client_operation( repo_path, contents, } => { - if !client.fs.directories(false).contains(&repo_path) { + if !client.fs().directories(false).contains(&repo_path) { return Err(TestError::Inapplicable); } for (path, _) in contents.iter() { - if !client.fs.files().contains(&repo_path.join(path)) { + if !client.fs().files().contains(&repo_path.join(path)) { return Err(TestError::Inapplicable); } } @@ -793,16 +793,16 @@ async fn apply_client_operation( .iter() .map(|(path, contents)| (path.as_path(), contents.clone())) .collect::>(); - if client.fs.metadata(&dot_git_dir).await?.is_none() { - client.fs.create_dir(&dot_git_dir).await?; + if client.fs().metadata(&dot_git_dir).await?.is_none() { + client.fs().create_dir(&dot_git_dir).await?; } - client.fs.set_index_for_repo(&dot_git_dir, &contents); + client.fs().set_index_for_repo(&dot_git_dir, &contents); } GitOperation::WriteGitBranch { repo_path, new_branch, } => { - if !client.fs.directories(false).contains(&repo_path) { + if !client.fs().directories(false).contains(&repo_path) { return Err(TestError::Inapplicable); } @@ -814,21 +814,21 @@ async fn apply_client_operation( ); let dot_git_dir = repo_path.join(".git"); - if client.fs.metadata(&dot_git_dir).await?.is_none() { - client.fs.create_dir(&dot_git_dir).await?; + if client.fs().metadata(&dot_git_dir).await?.is_none() { + client.fs().create_dir(&dot_git_dir).await?; } - client.fs.set_branch_name(&dot_git_dir, new_branch); + client.fs().set_branch_name(&dot_git_dir, new_branch); } GitOperation::WriteGitStatuses { repo_path, statuses, git_operation, } => { - if !client.fs.directories(false).contains(&repo_path) { + if !client.fs().directories(false).contains(&repo_path) { return Err(TestError::Inapplicable); } for (path, _) in statuses.iter() { - if !client.fs.files().contains(&repo_path.join(path)) { + if !client.fs().files().contains(&repo_path.join(path)) { return Err(TestError::Inapplicable); } } @@ -847,16 +847,16 @@ async fn apply_client_operation( .map(|(path, val)| (path.as_path(), val.clone())) .collect::>(); - if client.fs.metadata(&dot_git_dir).await?.is_none() { - client.fs.create_dir(&dot_git_dir).await?; + if client.fs().metadata(&dot_git_dir).await?.is_none() { + client.fs().create_dir(&dot_git_dir).await?; } if git_operation { client - .fs + .fs() .set_status_for_repo_via_git_operation(&dot_git_dir, statuses.as_slice()); } else { - client.fs.set_status_for_repo_via_working_copy_change( + client.fs().set_status_for_repo_via_working_copy_change( &dot_git_dir, statuses.as_slice(), ); @@ -1499,7 +1499,7 @@ impl TestPlan { // Invite a contact to the current call 0..=70 => { let available_contacts = - client.user_store.read_with(cx, |user_store, _| { + client.user_store().read_with(cx, |user_store, _| { user_store .contacts() .iter() @@ -1596,7 +1596,7 @@ impl TestPlan { .choose(&mut self.rng) .cloned() else { continue }; let project_root_name = root_name_for_project(&project, cx); - let mut paths = client.fs.paths(false); + let mut paths = client.fs().paths(false); paths.remove(0); let new_root_path = if paths.is_empty() || self.rng.gen() { Path::new("/").join(&self.next_root_dir_name(user_id)) @@ -1776,7 +1776,7 @@ impl TestPlan { let is_dir = self.rng.gen::(); let content; let mut path; - let dir_paths = client.fs.directories(false); + let dir_paths = client.fs().directories(false); if is_dir { content = String::new(); @@ -1786,7 +1786,7 @@ impl TestPlan { content = Alphanumeric.sample_string(&mut self.rng, 16); // Create a new file or overwrite an existing file - let file_paths = client.fs.files(); + let file_paths = client.fs().files(); if file_paths.is_empty() || self.rng.gen_bool(0.5) { path = dir_paths.choose(&mut self.rng).unwrap().clone(); path.push(gen_file_name(&mut self.rng)); @@ -1812,7 +1812,7 @@ impl TestPlan { client: &TestClient, ) -> Vec { let mut paths = client - .fs + .fs() .files() .into_iter() .filter(|path| path.starts_with(repo_path)) @@ -1829,7 +1829,7 @@ impl TestPlan { } let repo_path = client - .fs + .fs() .directories(false) .choose(&mut self.rng) .unwrap() @@ -1928,7 +1928,7 @@ async fn simulate_client( name: "the-fake-language-server", capabilities: lsp::LanguageServer::full_capabilities(), initializer: Some(Box::new({ - let fs = client.fs.clone(); + let fs = client.app_state.fs.clone(); move |fake_server: &mut FakeLanguageServer| { fake_server.handle_request::( |_, _| async move { @@ -1973,7 +1973,7 @@ async fn simulate_client( let background = cx.background(); let mut rng = background.rng(); let count = rng.gen_range::(1..3); - let files = fs.files(); + let files = fs.as_fake().files(); let files = (0..count) .map(|_| files.choose(&mut *rng).unwrap().clone()) .collect::>(); @@ -2023,7 +2023,7 @@ async fn simulate_client( ..Default::default() })) .await; - client.language_registry.add(Arc::new(language)); + client.app_state.languages.add(Arc::new(language)); while let Some(batch_id) = operation_rx.next().await { let Some((operation, applied)) = plan.lock().next_client_operation(&client, batch_id, &cx) else { break }; diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index edbb89e33955d00e29432f8f0509d778be3ef172..c42ed34de6d5ad24929b4f3b493d9ec181dae96e 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -3,9 +3,9 @@ mod contact_notification; mod face_pile; mod incoming_call_notification; mod notifications; +pub mod panel; mod project_shared_notification; mod sharing_status_indicator; -pub mod panel; use call::{ActiveCall, Room}; pub use collab_titlebar_item::CollabTitlebarItem; diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 53f7eee79a0cd0f52b2ef4aff93bed429769d9e6..c6940fbd14f2a57a91ff66fa338feb6afc394d2f 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -6,7 +6,7 @@ use anyhow::Result; use call::ActiveCall; use client::{proto::PeerId, Channel, ChannelStore, Client, Contact, User, UserStore}; use contact_finder::build_contact_finder; -use context_menu::ContextMenu; +use context_menu::{ContextMenu, ContextMenuItem}; use db::kvp::KEY_VALUE_STORE; use editor::{Cancel, Editor}; use futures::StreamExt; @@ -18,6 +18,7 @@ use gpui::{ MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg, }, geometry::{rect::RectF, vector::vec2f}, + impl_actions, platform::{CursorStyle, MouseButton, PromptLevel}, serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, @@ -36,8 +37,15 @@ use workspace::{ Workspace, }; +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct RemoveChannel { + channel_id: u64, +} + actions!(collab_panel, [ToggleFocus]); +impl_actions!(collab_panel, [RemoveChannel]); + const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; pub fn init(_client: Arc, cx: &mut AppContext) { @@ -49,6 +57,7 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::select_next); cx.add_action(CollabPanel::select_prev); cx.add_action(CollabPanel::confirm); + cx.add_action(CollabPanel::remove_channel); } #[derive(Debug, Default)] @@ -305,6 +314,8 @@ impl CollabPanel { let active_call = ActiveCall::global(cx); this.subscriptions .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx))); + this.subscriptions + .push(cx.observe(&this.channel_store, |this, _, cx| this.update_entries(cx))); this.subscriptions .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx))); @@ -1278,6 +1289,19 @@ impl CollabPanel { .on_click(MouseButton::Left, move |_, this, cx| { this.join_channel(channel_id, cx); }) + .on_click(MouseButton::Right, move |e, this, cx| { + this.context_menu.update(cx, |context_menu, cx| { + context_menu.show( + e.position, + gpui::elements::AnchorCorner::BottomLeft, + vec![ContextMenuItem::action( + "Remove Channel", + RemoveChannel { channel_id }, + )], + cx, + ); + }); + }) .into_any() } @@ -1564,14 +1588,13 @@ impl CollabPanel { } } } else if let Some((_editing_state, channel_name)) = self.take_editing_state(cx) { - dbg!(&channel_name); let create_channel = self.channel_store.update(cx, |channel_store, cx| { channel_store.create_channel(&channel_name, None) }); cx.foreground() .spawn(async move { - dbg!(create_channel.await).ok(); + create_channel.await.ok(); }) .detach(); } @@ -1600,6 +1623,36 @@ impl CollabPanel { } } + fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { + let channel_id = action.channel_id; + let channel_store = self.channel_store.clone(); + if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) { + let prompt_message = format!( + "Are you sure you want to remove the channel \"{}\"?", + channel.name + ); + let mut answer = + cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]); + let window_id = cx.window_id(); + cx.spawn(|_, mut cx| async move { + if answer.next().await == Some(0) { + if let Err(e) = channel_store + .update(&mut cx, |channels, cx| channels.remove_channel(channel_id)) + .await + { + cx.prompt( + window_id, + PromptLevel::Info, + &format!("Failed to remove channel: {}", e), + &["Ok"], + ); + } + } + }) + .detach(); + } + } + fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext) { let user_store = self.user_store.clone(); let prompt_message = format!( diff --git a/crates/collab_ui/src/panel/channel_modal.rs b/crates/collab_ui/src/panel/channel_modal.rs index 562536d58cd3863b1d57fc92ae33a1469fb4938f..fff1dc86244d8b76e62778f32eae3447b420b6d8 100644 --- a/crates/collab_ui/src/panel/channel_modal.rs +++ b/crates/collab_ui/src/panel/channel_modal.rs @@ -1,5 +1,5 @@ use editor::Editor; -use gpui::{elements::*, AnyViewHandle, Entity, View, ViewContext, ViewHandle, AppContext}; +use gpui::{elements::*, AnyViewHandle, AppContext, Entity, View, ViewContext, ViewHandle}; use menu::Cancel; use workspace::{item::ItemHandle, Modal}; @@ -62,12 +62,10 @@ impl View for ChannelModal { .constrained() .with_max_width(540.) .with_max_height(420.) - }) .on_click(gpui::platform::MouseButton::Left, |_, _, _| {}) // Capture click and down events - .on_down_out(gpui::platform::MouseButton::Left, |_, v, cx| { - v.dismiss(cx) - }).into_any_named("channel modal") + .on_down_out(gpui::platform::MouseButton::Left, |_, v, cx| v.dismiss(cx)) + .into_any_named("channel modal") } fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 8a4a72c26849f8d1b83a321787b4e97babcd7eb1..f49a879dc7196e3eef2bb77bdc7e52949aead1af 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -137,6 +137,7 @@ message Envelope { RespondToChannelInvite respond_to_channel_invite = 123; UpdateChannels update_channels = 124; JoinChannel join_channel = 125; + RemoveChannel remove_channel = 126; } } @@ -875,6 +876,11 @@ message JoinChannel { uint64 channel_id = 1; } +message RemoveChannel { + uint64 channel_id = 1; +} + + message CreateChannel { string name = 1; optional uint64 parent_id = 2; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index d71ddeed830f7ead89115508f1c5db888dd22eb8..f6985d69063a2aa63f15d81cfee14434ccee5d22 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -231,6 +231,7 @@ messages!( (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), (UpdateContacts, Foreground), + (RemoveChannel, Foreground), (UpdateChannels, Foreground), (UpdateDiagnosticSummary, Foreground), (UpdateFollowers, Foreground), @@ -296,6 +297,7 @@ request_messages!( (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), (JoinChannel, JoinRoomResponse), + (RemoveChannel, Ack), (RenameProjectEntry, ProjectEntryResponse), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 95077649a8b66467240e90f352f3bb321a9ffee9..4fe8b5d0f446366048ae1b8d60a46857a06b6003 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3412,6 +3412,7 @@ impl Workspace { pub fn test_new(project: ModelHandle, cx: &mut ViewContext) -> Self { let client = project.read(cx).client(); let user_store = project.read(cx).user_store(); + let channel_store = cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx)); let app_state = Arc::new(AppState { From b389dcc637d695d53a5ae883cb29fbcaf573505c Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Aug 2023 16:48:11 -0700 Subject: [PATCH 023/105] Add subchannel creation co-authored-by: max --- crates/collab/src/db.rs | 95 +++++++++++++++++++++++++++++++---- crates/collab/src/db/tests.rs | 28 +++++++++++ crates/collab/src/rpc.rs | 28 +++++++---- 3 files changed, 131 insertions(+), 20 deletions(-) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 066c93ec718f15a451164270dcaa0eb8b53dcd82..58607836cc4f44fab8acf4a88e5c972e92d9e9d8 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3093,6 +3093,22 @@ impl Database { self.transaction(move |tx| async move { let tx = tx; + if let Some(parent) = parent { + let channels = self.get_channel_ancestors(parent, &*tx).await?; + channel_member::Entity::find() + .filter(channel_member::Column::ChannelId.is_in(channels.iter().copied())) + .filter( + channel_member::Column::UserId + .eq(creator_id) + .and(channel_member::Column::Accepted.eq(true)), + ) + .one(&*tx) + .await? + .ok_or_else(|| { + anyhow!("User does not have the permissions to create this channel") + })?; + } + let channel = channel::ActiveModel { name: ActiveValue::Set(name.to_string()), ..Default::default() @@ -3175,11 +3191,6 @@ impl Database { let channels_to_remove = descendants.keys().copied().collect::>(); - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] - enum QueryUserIds { - UserId, - } - let members_to_notify: Vec = channel_member::Entity::find() .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.iter().copied())) .select_only() @@ -3325,11 +3336,6 @@ impl Database { self.transaction(|tx| async move { let tx = tx; - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] - enum QueryChannelIds { - ChannelId, - } - let starting_channel_ids: Vec = channel_member::Entity::find() .filter( channel_member::Column::UserId @@ -3368,6 +3374,65 @@ impl Database { .await } + pub async fn get_channel_members(&self, id: ChannelId) -> Result> { + self.transaction(|tx| async move { + let tx = tx; + let ancestor_ids = self.get_channel_ancestors(id, &*tx).await?; + let user_ids = channel_member::Entity::find() + .distinct() + .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) + .select_only() + .column(channel_member::Column::UserId) + .into_values::<_, QueryUserIds>() + .all(&*tx) + .await?; + Ok(user_ids) + }) + .await + } + + async fn get_channel_ancestors( + &self, + id: ChannelId, + tx: &DatabaseTransaction, + ) -> Result> { + let sql = format!( + r#" + WITH RECURSIVE channel_tree(child_id, parent_id) AS ( + SELECT CAST(NULL as INTEGER) as child_id, root_ids.column1 as parent_id + FROM (VALUES ({})) as root_ids + UNION + SELECT channel_parents.child_id, channel_parents.parent_id + FROM channel_parents, channel_tree + WHERE channel_parents.child_id = channel_tree.parent_id + ) + SELECT DISTINCT channel_tree.parent_id + FROM channel_tree + "#, + id + ); + + #[derive(FromQueryResult, Debug, PartialEq)] + pub struct ChannelParent { + pub parent_id: ChannelId, + } + + let stmt = Statement::from_string(self.pool.get_database_backend(), sql); + + let mut channel_ids_stream = channel_parent::Entity::find() + .from_raw_sql(stmt) + .into_model::() + .stream(&*tx) + .await?; + + let mut channel_ids = vec![]; + while let Some(channel_id) = channel_ids_stream.next().await { + channel_ids.push(channel_id?.parent_id); + } + + Ok(channel_ids) + } + async fn get_channel_descendants( &self, channel_ids: impl IntoIterator, @@ -3948,6 +4013,16 @@ pub struct WorktreeSettingsFile { pub content: String, } +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +enum QueryChannelIds { + ChannelId, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +enum QueryUserIds { + UserId, +} + #[cfg(test)] pub use test::*; diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 3a47097f7d91f721c2fdd0da54b854c543a97e77..2ffcef454b631f496b248330345cbdfc73daca03 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -899,7 +899,30 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .unwrap() .user_id; + let b_id = db + .create_user( + "user2@example.com", + false, + NewUserParams { + github_login: "user2".into(), + github_user_id: 6, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap(); + + db.invite_channel_member(zed_id, b_id, a_id, true) + .await + .unwrap(); + + db.respond_to_channel_invite(zed_id, b_id, true) + .await + .unwrap(); + let crdb_id = db .create_channel("crdb", Some(zed_id), "2", a_id) .await @@ -912,6 +935,11 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .create_channel("replace", Some(zed_id), "4", a_id) .await .unwrap(); + + let mut members = db.get_channel_members(replace_id).await.unwrap(); + members.sort(); + assert_eq!(members, &[a_id, b_id]); + let rust_id = db.create_root_channel("rust", "5", a_id).await.unwrap(); let cargo_id = db .create_channel("cargo", Some(rust_id), "6", a_id) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 1465c666016f39f6b5de2f1dcbf4def1dbb2eaaf..819a3dc4f67ef5bbcf71ebbaf052e6529e108c11 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2108,25 +2108,33 @@ async fn create_channel( live_kit.create_room(live_kit_room.clone()).await?; } + let parent_id = request.parent_id.map(|id| ChannelId::from_proto(id)); let id = db - .create_channel( - &request.name, - request.parent_id.map(|id| ChannelId::from_proto(id)), - &live_kit_room, - session.user_id, - ) + .create_channel(&request.name, parent_id, &live_kit_room, session.user_id) .await?; + response.send(proto::CreateChannelResponse { + channel_id: id.to_proto(), + })?; + let mut update = proto::UpdateChannels::default(); update.channels.push(proto::Channel { id: id.to_proto(), name: request.name, parent_id: request.parent_id, }); - session.peer.send(session.connection_id, update)?; - response.send(proto::CreateChannelResponse { - channel_id: id.to_proto(), - })?; + + if let Some(parent_id) = parent_id { + let member_ids = db.get_channel_members(parent_id).await?; + let connection_pool = session.connection_pool().await; + for member_id in member_ids { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + } else { + session.peer.send(session.connection_id, update)?; + } Ok(()) } From 6a404dfe317131508c30ecef5eaa761bd9294951 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 1 Aug 2023 18:20:25 -0700 Subject: [PATCH 024/105] Start work on adding sub-channels in the UI Co-authored-by: Mikayla --- crates/collab_ui/src/panel.rs | 315 +++++++++++++++++++--------------- 1 file changed, 179 insertions(+), 136 deletions(-) diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index c6940fbd14f2a57a91ff66fa338feb6afc394d2f..bca0da6176f6eca97bce8581d0f8deff283eb8dd 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -17,7 +17,10 @@ use gpui::{ Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState, MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg, }, - geometry::{rect::RectF, vector::vec2f}, + geometry::{ + rect::RectF, + vector::{vec2f, Vector2F}, + }, impl_actions, platform::{CursorStyle, MouseButton, PromptLevel}, serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle, @@ -42,9 +45,14 @@ struct RemoveChannel { channel_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct NewChannel { + channel_id: u64, +} + actions!(collab_panel, [ToggleFocus]); -impl_actions!(collab_panel, [RemoveChannel]); +impl_actions!(collab_panel, [RemoveChannel, NewChannel]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; @@ -58,11 +66,12 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::select_prev); cx.add_action(CollabPanel::confirm); cx.add_action(CollabPanel::remove_channel); + cx.add_action(CollabPanel::new_subchannel); } #[derive(Debug, Default)] pub struct ChannelEditingState { - root_channel: bool, + parent_id: Option, } pub struct CollabPanel { @@ -74,7 +83,7 @@ pub struct CollabPanel { filter_editor: ViewHandle, channel_name_editor: ViewHandle, channel_editing_state: Option, - entries: Vec, + entries: Vec, selection: Option, user_store: ModelHandle, channel_store: ModelHandle, @@ -109,7 +118,7 @@ enum Section { } #[derive(Clone, Debug)] -enum ContactEntry { +enum ListEntry { Header(Section, usize), CallParticipant { user: Arc, @@ -125,10 +134,13 @@ enum ContactEntry { peer_id: PeerId, is_last: bool, }, - ChannelInvite(Arc), IncomingRequest(Arc), OutgoingRequest(Arc), + ChannelInvite(Arc), Channel(Arc), + ChannelEditor { + depth: usize, + }, Contact { contact: Arc, calling: bool, @@ -166,7 +178,7 @@ impl CollabPanel { this.selection = this .entries .iter() - .position(|entry| !matches!(entry, ContactEntry::Header(_, _))); + .position(|entry| !matches!(entry, ListEntry::Header(_, _))); } } }) @@ -184,6 +196,7 @@ impl CollabPanel { cx.subscribe(&channel_name_editor, |this, _, event, cx| { if let editor::Event::Blurred = event { this.take_editing_state(cx); + this.update_entries(cx); cx.notify(); } }) @@ -196,7 +209,7 @@ impl CollabPanel { let current_project_id = this.project.read(cx).remote_id(); match &this.entries[ix] { - ContactEntry::Header(section, depth) => { + ListEntry::Header(section, depth) => { let is_collapsed = this.collapsed_sections.contains(section); this.render_header( *section, @@ -207,7 +220,7 @@ impl CollabPanel { cx, ) } - ContactEntry::CallParticipant { user, is_pending } => { + ListEntry::CallParticipant { user, is_pending } => { Self::render_call_participant( user, *is_pending, @@ -215,7 +228,7 @@ impl CollabPanel { &theme.collab_panel, ) } - ContactEntry::ParticipantProject { + ListEntry::ParticipantProject { project_id, worktree_root_names, host_user_id, @@ -230,7 +243,7 @@ impl CollabPanel { &theme.collab_panel, cx, ), - ContactEntry::ParticipantScreen { peer_id, is_last } => { + ListEntry::ParticipantScreen { peer_id, is_last } => { Self::render_participant_screen( *peer_id, *is_last, @@ -239,17 +252,17 @@ impl CollabPanel { cx, ) } - ContactEntry::Channel(channel) => { + ListEntry::Channel(channel) => { Self::render_channel(&*channel, &theme.collab_panel, is_selected, cx) } - ContactEntry::ChannelInvite(channel) => Self::render_channel_invite( + ListEntry::ChannelInvite(channel) => Self::render_channel_invite( channel.clone(), this.channel_store.clone(), &theme.collab_panel, is_selected, cx, ), - ContactEntry::IncomingRequest(user) => Self::render_contact_request( + ListEntry::IncomingRequest(user) => Self::render_contact_request( user.clone(), this.user_store.clone(), &theme.collab_panel, @@ -257,7 +270,7 @@ impl CollabPanel { is_selected, cx, ), - ContactEntry::OutgoingRequest(user) => Self::render_contact_request( + ListEntry::OutgoingRequest(user) => Self::render_contact_request( user.clone(), this.user_store.clone(), &theme.collab_panel, @@ -265,7 +278,7 @@ impl CollabPanel { is_selected, cx, ), - ContactEntry::Contact { contact, calling } => Self::render_contact( + ListEntry::Contact { contact, calling } => Self::render_contact( contact, *calling, &this.project, @@ -273,6 +286,9 @@ impl CollabPanel { is_selected, cx, ), + ListEntry::ChannelEditor { depth } => { + this.render_channel_editor(&theme.collab_panel, *depth, cx) + } } }); @@ -369,13 +385,6 @@ impl CollabPanel { ); } - fn is_editing_root_channel(&self) -> bool { - self.channel_editing_state - .as_ref() - .map(|state| state.root_channel) - .unwrap_or(false) - } - fn update_entries(&mut self, cx: &mut ViewContext) { let channel_store = self.channel_store.read(cx); let user_store = self.user_store.read(cx); @@ -407,13 +416,13 @@ impl CollabPanel { )); if !matches.is_empty() { let user_id = user.id; - participant_entries.push(ContactEntry::CallParticipant { + participant_entries.push(ListEntry::CallParticipant { user, is_pending: false, }); let mut projects = room.local_participant().projects.iter().peekable(); while let Some(project) = projects.next() { - participant_entries.push(ContactEntry::ParticipantProject { + participant_entries.push(ListEntry::ParticipantProject { project_id: project.id, worktree_root_names: project.worktree_root_names.clone(), host_user_id: user_id, @@ -444,13 +453,13 @@ impl CollabPanel { for mat in matches { let user_id = mat.candidate_id as u64; let participant = &room.remote_participants()[&user_id]; - participant_entries.push(ContactEntry::CallParticipant { + participant_entries.push(ListEntry::CallParticipant { user: participant.user.clone(), is_pending: false, }); let mut projects = participant.projects.iter().peekable(); while let Some(project) = projects.next() { - participant_entries.push(ContactEntry::ParticipantProject { + participant_entries.push(ListEntry::ParticipantProject { project_id: project.id, worktree_root_names: project.worktree_root_names.clone(), host_user_id: participant.user.id, @@ -458,7 +467,7 @@ impl CollabPanel { }); } if !participant.video_tracks.is_empty() { - participant_entries.push(ContactEntry::ParticipantScreen { + participant_entries.push(ListEntry::ParticipantScreen { peer_id: participant.peer_id, is_last: true, }); @@ -486,22 +495,20 @@ impl CollabPanel { &Default::default(), executor.clone(), )); - participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant { + participant_entries.extend(matches.iter().map(|mat| ListEntry::CallParticipant { user: room.pending_participants()[mat.candidate_id].clone(), is_pending: true, })); if !participant_entries.is_empty() { - self.entries - .push(ContactEntry::Header(Section::ActiveCall, 0)); + self.entries.push(ListEntry::Header(Section::ActiveCall, 0)); if !self.collapsed_sections.contains(&Section::ActiveCall) { self.entries.extend(participant_entries); } } } - self.entries - .push(ContactEntry::Header(Section::Channels, 0)); + self.entries.push(ListEntry::Header(Section::Channels, 0)); let channels = channel_store.channels(); if !channels.is_empty() { @@ -525,15 +532,25 @@ impl CollabPanel { &Default::default(), executor.clone(), )); - self.entries.extend( - matches - .iter() - .map(|mat| ContactEntry::Channel(channels[mat.candidate_id].clone())), - ); + if let Some(state) = &self.channel_editing_state { + if state.parent_id.is_none() { + self.entries.push(ListEntry::ChannelEditor { depth: 0 }); + } + } + for mat in matches { + let channel = &channels[mat.candidate_id]; + self.entries.push(ListEntry::Channel(channel.clone())); + if let Some(state) = &self.channel_editing_state { + if state.parent_id == Some(channel.id) { + self.entries.push(ListEntry::ChannelEditor { + depth: channel.depth + 1, + }); + } + } + } } - self.entries - .push(ContactEntry::Header(Section::Contacts, 0)); + self.entries.push(ListEntry::Header(Section::Contacts, 0)); let mut request_entries = Vec::new(); let channel_invites = channel_store.channel_invitations(); @@ -556,9 +573,9 @@ impl CollabPanel { executor.clone(), )); request_entries.extend( - matches.iter().map(|mat| { - ContactEntry::ChannelInvite(channel_invites[mat.candidate_id].clone()) - }), + matches + .iter() + .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())), ); } @@ -587,7 +604,7 @@ impl CollabPanel { request_entries.extend( matches .iter() - .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())), + .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())), ); } @@ -616,13 +633,12 @@ impl CollabPanel { request_entries.extend( matches .iter() - .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), + .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())), ); } if !request_entries.is_empty() { - self.entries - .push(ContactEntry::Header(Section::Requests, 1)); + self.entries.push(ListEntry::Header(Section::Requests, 1)); if !self.collapsed_sections.contains(&Section::Requests) { self.entries.append(&mut request_entries); } @@ -668,12 +684,12 @@ impl CollabPanel { (offline_contacts, Section::Offline), ] { if !matches.is_empty() { - self.entries.push(ContactEntry::Header(section, 1)); + self.entries.push(ListEntry::Header(section, 1)); if !self.collapsed_sections.contains(§ion) { let active_call = &ActiveCall::global(cx).read(cx); for mat in matches { let contact = &contacts[mat.candidate_id]; - self.entries.push(ContactEntry::Contact { + self.entries.push(ListEntry::Contact { contact: contact.clone(), calling: active_call.pending_invites().contains(&contact.user.id), }); @@ -1072,15 +1088,7 @@ impl CollabPanel { render_icon_button(&theme.collab_panel.add_contact_button, "icons/plus_16.svg") }) .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, |_, this, cx| { - if this.channel_editing_state.is_none() { - this.channel_editing_state = - Some(ChannelEditingState { root_channel: true }); - } - - cx.focus(this.channel_name_editor.as_any()); - cx.notify(); - }) + .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx)) .with_tooltip::( 0, "Add or join a channel".into(), @@ -1092,13 +1100,6 @@ impl CollabPanel { _ => None, }; - let addition = match section { - Section::Channels if self.is_editing_root_channel() => { - Some(ChildView::new(self.channel_name_editor.as_any(), cx)) - } - _ => None, - }; - let can_collapse = depth > 0; let icon_size = (&theme.collab_panel).section_icon_size; MouseEventHandler::::new(section as usize, cx, |state, _| { @@ -1112,44 +1113,40 @@ impl CollabPanel { &theme.collab_panel.header_row }; - Flex::column() - .with_child( - Flex::row() - .with_children(if can_collapse { - Some( - Svg::new(if is_collapsed { - "icons/chevron_right_8.svg" - } else { - "icons/chevron_down_8.svg" - }) - .with_color(header_style.text.color) - .constrained() - .with_max_width(icon_size) - .with_max_height(icon_size) - .aligned() - .constrained() - .with_width(icon_size) - .contained() - .with_margin_right( - theme.collab_panel.contact_username.container.margin.left, - ), - ) + Flex::row() + .with_children(if can_collapse { + Some( + Svg::new(if is_collapsed { + "icons/chevron_right_8.svg" } else { - None + "icons/chevron_down_8.svg" }) - .with_child( - Label::new(text, header_style.text.clone()) - .aligned() - .left() - .flex(1., true), - ) - .with_children(button.map(|button| button.aligned().right())) + .with_color(header_style.text.color) + .constrained() + .with_max_width(icon_size) + .with_max_height(icon_size) + .aligned() .constrained() - .with_height(theme.collab_panel.row_height) + .with_width(icon_size) .contained() - .with_style(header_style.container), + .with_margin_right( + theme.collab_panel.contact_username.container.margin.left, + ), + ) + } else { + None + }) + .with_child( + Label::new(text, header_style.text.clone()) + .aligned() + .left() + .flex(1., true), ) - .with_children(addition) + .with_children(button.map(|button| button.aligned().right())) + .constrained() + .with_height(theme.collab_panel.row_height) + .contained() + .with_style(header_style.container) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, this, cx| { @@ -1258,6 +1255,15 @@ impl CollabPanel { event_handler.into_any() } + fn render_channel_editor( + &self, + theme: &theme::CollabPanel, + depth: usize, + cx: &AppContext, + ) -> AnyElement { + ChildView::new(&self.channel_name_editor, cx).into_any() + } + fn render_channel( channel: &Channel, theme: &theme::CollabPanel, @@ -1285,22 +1291,13 @@ impl CollabPanel { .with_height(theme.row_height) .contained() .with_style(*theme.contact_row.in_state(is_selected).style_for(state)) + .with_margin_left(10. * channel.depth as f32) }) .on_click(MouseButton::Left, move |_, this, cx| { this.join_channel(channel_id, cx); }) .on_click(MouseButton::Right, move |e, this, cx| { - this.context_menu.update(cx, |context_menu, cx| { - context_menu.show( - e.position, - gpui::elements::AnchorCorner::BottomLeft, - vec![ContextMenuItem::action( - "Remove Channel", - RemoveChannel { channel_id }, - )], - cx, - ); - }); + this.deploy_channel_context_menu(e.position, channel_id, cx); }) .into_any() } @@ -1489,6 +1486,25 @@ impl CollabPanel { .into_any() } + fn deploy_channel_context_menu( + &mut self, + position: Vector2F, + channel_id: u64, + cx: &mut ViewContext, + ) { + self.context_menu.update(cx, |context_menu, cx| { + context_menu.show( + position, + gpui::elements::AnchorCorner::BottomLeft, + vec![ + ContextMenuItem::action("New Channel", NewChannel { channel_id }), + ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), + ], + cx, + ); + }); + } + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { let mut did_clear = self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx) > 0 { @@ -1553,15 +1569,15 @@ impl CollabPanel { if let Some(selection) = self.selection { if let Some(entry) = self.entries.get(selection) { match entry { - ContactEntry::Header(section, _) => { + ListEntry::Header(section, _) => { self.toggle_expanded(*section, cx); } - ContactEntry::Contact { contact, calling } => { + ListEntry::Contact { contact, calling } => { if contact.online && !contact.busy && !calling { self.call(contact.user.id, Some(self.project.clone()), cx); } } - ContactEntry::ParticipantProject { + ListEntry::ParticipantProject { project_id, host_user_id, .. @@ -1577,7 +1593,7 @@ impl CollabPanel { .detach_and_log_err(cx); } } - ContactEntry::ParticipantScreen { peer_id, .. } => { + ListEntry::ParticipantScreen { peer_id, .. } => { if let Some(workspace) = self.workspace.upgrade(cx) { workspace.update(cx, |workspace, cx| { workspace.open_shared_screen(*peer_id, cx) @@ -1587,9 +1603,9 @@ impl CollabPanel { _ => {} } } - } else if let Some((_editing_state, channel_name)) = self.take_editing_state(cx) { + } else if let Some((editing_state, channel_name)) = self.take_editing_state(cx) { let create_channel = self.channel_store.update(cx, |channel_store, cx| { - channel_store.create_channel(&channel_name, None) + channel_store.create_channel(&channel_name, editing_state.parent_id) }); cx.foreground() @@ -1623,6 +1639,28 @@ impl CollabPanel { } } + fn new_root_channel(&mut self, cx: &mut ViewContext) { + if self.channel_editing_state.is_none() { + self.channel_editing_state = Some(ChannelEditingState { parent_id: None }); + self.update_entries(cx); + } + + cx.focus(self.channel_name_editor.as_any()); + cx.notify(); + } + + fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext) { + if self.channel_editing_state.is_none() { + self.channel_editing_state = Some(ChannelEditingState { + parent_id: Some(action.channel_id), + }); + self.update_entries(cx); + } + + cx.focus(self.channel_name_editor.as_any()); + cx.notify(); + } + fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { let channel_id = action.channel_id; let channel_store = self.channel_store.clone(); @@ -1838,9 +1876,9 @@ impl Panel for CollabPanel { } } -impl ContactEntry { +impl ListEntry { fn is_selectable(&self) -> bool { - if let ContactEntry::Header(_, 0) = self { + if let ListEntry::Header(_, 0) = self { false } else { true @@ -1848,24 +1886,24 @@ impl ContactEntry { } } -impl PartialEq for ContactEntry { +impl PartialEq for ListEntry { fn eq(&self, other: &Self) -> bool { match self { - ContactEntry::Header(section_1, depth_1) => { - if let ContactEntry::Header(section_2, depth_2) = other { + ListEntry::Header(section_1, depth_1) => { + if let ListEntry::Header(section_2, depth_2) = other { return section_1 == section_2 && depth_1 == depth_2; } } - ContactEntry::CallParticipant { user: user_1, .. } => { - if let ContactEntry::CallParticipant { user: user_2, .. } = other { + ListEntry::CallParticipant { user: user_1, .. } => { + if let ListEntry::CallParticipant { user: user_2, .. } = other { return user_1.id == user_2.id; } } - ContactEntry::ParticipantProject { + ListEntry::ParticipantProject { project_id: project_id_1, .. } => { - if let ContactEntry::ParticipantProject { + if let ListEntry::ParticipantProject { project_id: project_id_2, .. } = other @@ -1873,46 +1911,51 @@ impl PartialEq for ContactEntry { return project_id_1 == project_id_2; } } - ContactEntry::ParticipantScreen { + ListEntry::ParticipantScreen { peer_id: peer_id_1, .. } => { - if let ContactEntry::ParticipantScreen { + if let ListEntry::ParticipantScreen { peer_id: peer_id_2, .. } = other { return peer_id_1 == peer_id_2; } } - ContactEntry::Channel(channel_1) => { - if let ContactEntry::Channel(channel_2) = other { + ListEntry::Channel(channel_1) => { + if let ListEntry::Channel(channel_2) = other { return channel_1.id == channel_2.id; } } - ContactEntry::ChannelInvite(channel_1) => { - if let ContactEntry::ChannelInvite(channel_2) = other { + ListEntry::ChannelInvite(channel_1) => { + if let ListEntry::ChannelInvite(channel_2) = other { return channel_1.id == channel_2.id; } } - ContactEntry::IncomingRequest(user_1) => { - if let ContactEntry::IncomingRequest(user_2) = other { + ListEntry::IncomingRequest(user_1) => { + if let ListEntry::IncomingRequest(user_2) = other { return user_1.id == user_2.id; } } - ContactEntry::OutgoingRequest(user_1) => { - if let ContactEntry::OutgoingRequest(user_2) = other { + ListEntry::OutgoingRequest(user_1) => { + if let ListEntry::OutgoingRequest(user_2) = other { return user_1.id == user_2.id; } } - ContactEntry::Contact { + ListEntry::Contact { contact: contact_1, .. } => { - if let ContactEntry::Contact { + if let ListEntry::Contact { contact: contact_2, .. } = other { return contact_1.user.id == contact_2.user.id; } } + ListEntry::ChannelEditor { depth } => { + if let ListEntry::ChannelEditor { depth: other_depth } = other { + return depth == other_depth; + } + } } false } From 7145f47454e9ad37525043a47a73389ce2919259 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Aug 2023 18:42:14 -0700 Subject: [PATCH 025/105] Fix a few bugs in how channels are moved around --- crates/collab/src/rpc.rs | 2 +- crates/collab_ui/src/panel.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 819a3dc4f67ef5bbcf71ebbaf052e6529e108c11..eaa3eb8261d7fb6f80cda8f8500380cbe4303681 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2364,7 +2364,7 @@ fn build_initial_channels_update( update.channels.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, - parent_id: None, + parent_id: channel.parent_id.map(|id| id.to_proto()), }); } diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index bca0da6176f6eca97bce8581d0f8deff283eb8dd..1973ddd9f6d492130ca03db0e5bde0976d5ce551 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -511,7 +511,7 @@ impl CollabPanel { self.entries.push(ListEntry::Header(Section::Channels, 0)); let channels = channel_store.channels(); - if !channels.is_empty() { + if !(channels.is_empty() && self.channel_editing_state.is_none()) { self.match_candidates.clear(); self.match_candidates .extend( @@ -1291,7 +1291,7 @@ impl CollabPanel { .with_height(theme.row_height) .contained() .with_style(*theme.contact_row.in_state(is_selected).style_for(state)) - .with_margin_left(10. * channel.depth as f32) + .with_margin_left(20. * channel.depth as f32) }) .on_click(MouseButton::Left, move |_, this, cx| { this.join_channel(channel_id, cx); From 61a6892b8cdc95fcb1b4ef1a0f64436a06263492 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Aug 2023 19:17:51 -0700 Subject: [PATCH 026/105] WIP: Broadcast room updates to channel members --- crates/collab/src/db.rs | 102 +++++++++++++++++++++++++++------------ crates/collab/src/rpc.rs | 28 +++++++++-- 2 files changed, 95 insertions(+), 35 deletions(-) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 58607836cc4f44fab8acf4a88e5c972e92d9e9d8..1a89978c3821f7dbc6a7eb95ee7b02f06b354edf 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1178,7 +1178,7 @@ impl Database { user_id: UserId, connection: ConnectionId, live_kit_room: &str, - ) -> Result { + ) -> Result { self.transaction(|tx| async move { let room = room::ActiveModel { live_kit_room: ActiveValue::set(live_kit_room.into()), @@ -1217,7 +1217,7 @@ impl Database { calling_connection: ConnectionId, called_user_id: UserId, initial_project_id: Option, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { room_participant::ActiveModel { room_id: ActiveValue::set(room_id), @@ -1246,7 +1246,7 @@ impl Database { &self, room_id: RoomId, called_user_id: UserId, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { room_participant::Entity::delete_many() .filter( @@ -1266,7 +1266,7 @@ impl Database { &self, expected_room_id: Option, user_id: UserId, - ) -> Result>> { + ) -> Result>> { self.optional_room_transaction(|tx| async move { let mut filter = Condition::all() .add(room_participant::Column::UserId.eq(user_id)) @@ -1303,7 +1303,7 @@ impl Database { room_id: RoomId, calling_connection: ConnectionId, called_user_id: UserId, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( @@ -1340,7 +1340,7 @@ impl Database { user_id: UserId, channel_id: Option, connection: ConnectionId, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { if let Some(channel_id) = channel_id { channel_member::Entity::find() @@ -1868,7 +1868,7 @@ impl Database { project_id: ProjectId, leader_connection: ConnectionId, follower_connection: ConnectionId, - ) -> Result> { + ) -> Result> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { follower::ActiveModel { @@ -1898,7 +1898,7 @@ impl Database { project_id: ProjectId, leader_connection: ConnectionId, follower_connection: ConnectionId, - ) -> Result> { + ) -> Result> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { follower::Entity::delete_many() @@ -1930,7 +1930,7 @@ impl Database { room_id: RoomId, connection: ConnectionId, location: proto::ParticipantLocation, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async { let tx = tx; let location_kind; @@ -2043,7 +2043,7 @@ impl Database { }) } - async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result { + async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result { let db_room = room::Entity::find_by_id(room_id) .one(tx) .await? @@ -2147,12 +2147,22 @@ impl Database { }); } - Ok(proto::Room { - id: db_room.id.to_proto(), - live_kit_room: db_room.live_kit_room, - participants: participants.into_values().collect(), - pending_participants, - followers, + let channel_users = + if let Some(channel) = db_room.find_related(channel::Entity).one(tx).await? { + self.get_channel_members_internal(channel.id, tx).await? + } else { + Vec::new() + }; + + Ok(ChannelRoom { + room: proto::Room { + id: db_room.id.to_proto(), + live_kit_room: db_room.live_kit_room, + participants: participants.into_values().collect(), + pending_participants, + followers, + }, + channel_participants: channel_users, }) } @@ -2183,7 +2193,7 @@ impl Database { room_id: RoomId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( @@ -2254,7 +2264,7 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, - ) -> Result)>> { + ) -> Result)>> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; @@ -2281,7 +2291,7 @@ impl Database { project_id: ProjectId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], - ) -> Result)>> { + ) -> Result)>> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { let project = project::Entity::find_by_id(project_id) @@ -2858,7 +2868,7 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, - ) -> Result> { + ) -> Result> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { let result = project_collaborator::Entity::delete_many() @@ -3377,20 +3387,29 @@ impl Database { pub async fn get_channel_members(&self, id: ChannelId) -> Result> { self.transaction(|tx| async move { let tx = tx; - let ancestor_ids = self.get_channel_ancestors(id, &*tx).await?; - let user_ids = channel_member::Entity::find() - .distinct() - .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) - .select_only() - .column(channel_member::Column::UserId) - .into_values::<_, QueryUserIds>() - .all(&*tx) - .await?; + let user_ids = self.get_channel_members_internal(id, &*tx).await?; Ok(user_ids) }) .await } + pub async fn get_channel_members_internal( + &self, + id: ChannelId, + tx: &DatabaseTransaction, + ) -> Result> { + let ancestor_ids = self.get_channel_ancestors(id, tx).await?; + let user_ids = channel_member::Entity::find() + .distinct() + .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) + .select_only() + .column(channel_member::Column::UserId) + .into_values::<_, QueryUserIds>() + .all(&*tx) + .await?; + Ok(user_ids) + } + async fn get_channel_ancestors( &self, id: ChannelId, @@ -3913,8 +3932,27 @@ id_type!(ServerId); id_type!(SignupId); id_type!(UserId); -pub struct RejoinedRoom { +pub struct ChannelRoom { pub room: proto::Room, + pub channel_participants: Vec, +} + +impl Deref for ChannelRoom { + type Target = proto::Room; + + fn deref(&self) -> &Self::Target { + &self.room + } +} + +impl DerefMut for ChannelRoom { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.room + } +} + +pub struct RejoinedRoom { + pub room: ChannelRoom, pub rejoined_projects: Vec, pub reshared_projects: Vec, } @@ -3951,14 +3989,14 @@ pub struct RejoinedWorktree { } pub struct LeftRoom { - pub room: proto::Room, + pub room: ChannelRoom, pub left_projects: HashMap, pub canceled_calls_to_user_ids: Vec, pub deleted: bool, } pub struct RefreshedRoom { - pub room: proto::Room, + pub room: ChannelRoom, pub stale_participant_user_ids: Vec, pub canceled_calls_to_user_ids: Vec, } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index eaa3eb8261d7fb6f80cda8f8500380cbe4303681..4d30d174853f55b9f20ccb4ee1ce80ed45606753 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2,7 +2,7 @@ mod connection_pool; use crate::{ auth, - db::{self, ChannelId, Database, ProjectId, RoomId, ServerId, User, UserId}, + db::{self, ChannelId, ChannelRoom, Database, ProjectId, RoomId, ServerId, User, UserId}, executor::Executor, AppState, Result, }; @@ -2426,7 +2426,10 @@ fn contact_for_user( } } -fn room_updated(room: &proto::Room, peer: &Peer) { +fn room_updated(room: &ChannelRoom, peer: &Peer, pool: &ConnectionPool) { + let channel_ids = &room.channel_participants; + let room = &room.room; + broadcast( None, room.participants @@ -2441,6 +2444,21 @@ fn room_updated(room: &proto::Room, peer: &Peer) { ) }, ); + + broadcast( + None, + channel_ids + .iter() + .flat_map(|user_id| pool.user_connection_ids(*user_id)), + |peer_id| { + peer.send( + peer_id.into(), + proto::RoomUpdated { + room: Some(room.clone()), + }, + ) + }, + ); } async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> { @@ -2491,7 +2509,11 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { project_left(project, session); } - room_updated(&left_room.room, &session.peer); + { + let connection_pool = session.connection_pool().await; + room_updated(&left_room.room, &session.peer, &connection_pool); + } + room_id = RoomId::from_proto(left_room.room.id); canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids); live_kit_room = mem::take(&mut left_room.room.live_kit_room); From a9de73739a1bdbd4e2bcbd0fd572d5ecc0f47de0 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 2 Aug 2023 12:15:06 -0700 Subject: [PATCH 027/105] WIP --- crates/client/src/channel_store.rs | 27 ++-- crates/collab/src/db.rs | 175 ++++++++++++++++------- crates/collab/src/db/room.rs | 2 +- crates/collab/src/db/tests.rs | 10 +- crates/collab/src/rpc.rs | 134 +++++++++++++---- crates/collab/src/tests/channel_tests.rs | 29 +++- crates/rpc/proto/zed.proto | 10 +- crates/rpc/src/proto.rs | 2 +- 8 files changed, 289 insertions(+), 100 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 99501bbd2a119c31d21c95c0dae2d0b6e4230e08..5218c56891461820711407d17cd80fa44e783e1a 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -1,13 +1,17 @@ use crate::{Client, Subscription, User, UserStore}; use anyhow::Result; +use collections::HashMap; use futures::Future; use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, TypedEnvelope}; use std::sync::Arc; +type ChannelId = u64; + pub struct ChannelStore { channels: Vec>, channel_invitations: Vec>, + channel_participants: HashMap>, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, @@ -15,9 +19,9 @@ pub struct ChannelStore { #[derive(Clone, Debug, PartialEq)] pub struct Channel { - pub id: u64, + pub id: ChannelId, pub name: String, - pub parent_id: Option, + pub parent_id: Option, pub depth: usize, } @@ -37,6 +41,7 @@ impl ChannelStore { Self { channels: vec![], channel_invitations: vec![], + channel_participants: Default::default(), client, user_store, _rpc_subscription: rpc_subscription, @@ -51,15 +56,15 @@ impl ChannelStore { &self.channel_invitations } - pub fn channel_for_id(&self, channel_id: u64) -> Option> { + pub fn channel_for_id(&self, channel_id: ChannelId) -> Option> { self.channels.iter().find(|c| c.id == channel_id).cloned() } pub fn create_channel( &self, name: &str, - parent_id: Option, - ) -> impl Future> { + parent_id: Option, + ) -> impl Future> { let client = self.client.clone(); let name = name.to_owned(); async move { @@ -72,7 +77,7 @@ impl ChannelStore { pub fn invite_member( &self, - channel_id: u64, + channel_id: ChannelId, user_id: u64, admin: bool, ) -> impl Future> { @@ -91,7 +96,7 @@ impl ChannelStore { pub fn respond_to_channel_invite( &mut self, - channel_id: u64, + channel_id: ChannelId, accept: bool, ) -> impl Future> { let client = self.client.clone(); @@ -107,7 +112,7 @@ impl ChannelStore { false } - pub fn remove_channel(&self, channel_id: u64) -> impl Future> { + pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future> { let client = self.client.clone(); async move { client.request(proto::RemoveChannel { channel_id }).await?; @@ -117,7 +122,7 @@ impl ChannelStore { pub fn remove_member( &self, - channel_id: u64, + channel_id: ChannelId, user_id: u64, cx: &mut ModelContext, ) -> Task> { @@ -126,13 +131,13 @@ impl ChannelStore { pub fn channel_members( &self, - channel_id: u64, + channel_id: ChannelId, cx: &mut ModelContext, ) -> Task>>> { todo!() } - pub fn add_guest_channel(&self, channel_id: u64) -> Task> { + pub fn add_guest_channel(&self, channel_id: ChannelId) -> Task> { todo!() } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 1a89978c3821f7dbc6a7eb95ee7b02f06b354edf..ad87266e7ddad7fad8c70ddc69c6c3d5c567d175 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -212,7 +212,13 @@ impl Database { .map(|participant| participant.user_id), ); - let room = self.get_room(room_id, &tx).await?; + let (channel_id, room) = self.get_channel_room(room_id, &tx).await?; + let channel_members = if let Some(channel_id) = channel_id { + self.get_channel_members_internal(channel_id, &tx).await? + } else { + Vec::new() + }; + // Delete the room if it becomes empty. if room.participants.is_empty() { project::Entity::delete_many() @@ -224,6 +230,8 @@ impl Database { Ok(RefreshedRoom { room, + channel_id, + channel_members, stale_participant_user_ids, canceled_calls_to_user_ids, }) @@ -1178,7 +1186,7 @@ impl Database { user_id: UserId, connection: ConnectionId, live_kit_room: &str, - ) -> Result { + ) -> Result { self.transaction(|tx| async move { let room = room::ActiveModel { live_kit_room: ActiveValue::set(live_kit_room.into()), @@ -1217,7 +1225,7 @@ impl Database { calling_connection: ConnectionId, called_user_id: UserId, initial_project_id: Option, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { room_participant::ActiveModel { room_id: ActiveValue::set(room_id), @@ -1246,7 +1254,7 @@ impl Database { &self, room_id: RoomId, called_user_id: UserId, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { room_participant::Entity::delete_many() .filter( @@ -1266,7 +1274,7 @@ impl Database { &self, expected_room_id: Option, user_id: UserId, - ) -> Result>> { + ) -> Result>> { self.optional_room_transaction(|tx| async move { let mut filter = Condition::all() .add(room_participant::Column::UserId.eq(user_id)) @@ -1303,7 +1311,7 @@ impl Database { room_id: RoomId, calling_connection: ConnectionId, called_user_id: UserId, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( @@ -1340,7 +1348,7 @@ impl Database { user_id: UserId, channel_id: Option, connection: ConnectionId, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { if let Some(channel_id) = channel_id { channel_member::Entity::find() @@ -1396,7 +1404,16 @@ impl Database { } let room = self.get_room(room_id, &tx).await?; - Ok(room) + let channel_members = if let Some(channel_id) = channel_id { + self.get_channel_members_internal(channel_id, &tx).await? + } else { + Vec::new() + }; + Ok(JoinRoom { + room, + channel_id, + channel_members, + }) }) .await } @@ -1690,9 +1707,18 @@ impl Database { }); } - let room = self.get_room(room_id, &tx).await?; + let (channel_id, room) = self.get_channel_room(room_id, &tx).await?; + + let channel_members = if let Some(channel_id) = channel_id { + self.get_channel_members_internal(channel_id, &tx).await? + } else { + Vec::new() + }; + Ok(RejoinedRoom { room, + channel_id, + channel_members, rejoined_projects, reshared_projects, }) @@ -1833,7 +1859,7 @@ impl Database { .exec(&*tx) .await?; - let room = self.get_room(room_id, &tx).await?; + let (channel_id, room) = self.get_channel_room(room_id, &tx).await?; let deleted = if room.participants.is_empty() { let result = room::Entity::delete_by_id(room_id) .filter(room::Column::ChannelId.is_null()) @@ -1844,8 +1870,15 @@ impl Database { false }; + let channel_members = if let Some(channel_id) = channel_id { + self.get_channel_members_internal(channel_id, &tx).await? + } else { + Vec::new() + }; let left_room = LeftRoom { room, + channel_id, + channel_members, left_projects, canceled_calls_to_user_ids, deleted, @@ -1868,7 +1901,7 @@ impl Database { project_id: ProjectId, leader_connection: ConnectionId, follower_connection: ConnectionId, - ) -> Result> { + ) -> Result> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { follower::ActiveModel { @@ -1898,7 +1931,7 @@ impl Database { project_id: ProjectId, leader_connection: ConnectionId, follower_connection: ConnectionId, - ) -> Result> { + ) -> Result> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { follower::Entity::delete_many() @@ -1930,7 +1963,7 @@ impl Database { room_id: RoomId, connection: ConnectionId, location: proto::ParticipantLocation, - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async { let tx = tx; let location_kind; @@ -2042,8 +2075,16 @@ impl Database { }), }) } + async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result { + let (_, room) = self.get_channel_room(room_id, tx).await?; + Ok(room) + } - async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result { + async fn get_channel_room( + &self, + room_id: RoomId, + tx: &DatabaseTransaction, + ) -> Result<(Option, proto::Room)> { let db_room = room::Entity::find_by_id(room_id) .one(tx) .await? @@ -2147,6 +2188,28 @@ impl Database { }); } + Ok(( + db_room.channel_id, + proto::Room { + id: db_room.id.to_proto(), + live_kit_room: db_room.live_kit_room, + participants: participants.into_values().collect(), + pending_participants, + followers, + }, + )) + } + + async fn get_channel_members_for_room( + &self, + room_id: RoomId, + tx: &DatabaseTransaction, + ) -> Result> { + let db_room = room::Model { + id: room_id, + ..Default::default() + }; + let channel_users = if let Some(channel) = db_room.find_related(channel::Entity).one(tx).await? { self.get_channel_members_internal(channel.id, tx).await? @@ -2154,16 +2217,7 @@ impl Database { Vec::new() }; - Ok(ChannelRoom { - room: proto::Room { - id: db_room.id.to_proto(), - live_kit_room: db_room.live_kit_room, - participants: participants.into_values().collect(), - pending_participants, - followers, - }, - channel_participants: channel_users, - }) + Ok(channel_users) } // projects @@ -2193,7 +2247,7 @@ impl Database { room_id: RoomId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], - ) -> Result> { + ) -> Result> { self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( @@ -2264,7 +2318,7 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, - ) -> Result)>> { + ) -> Result)>> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; @@ -2291,7 +2345,7 @@ impl Database { project_id: ProjectId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], - ) -> Result)>> { + ) -> Result)>> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { let project = project::Entity::find_by_id(project_id) @@ -2868,7 +2922,7 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, - ) -> Result> { + ) -> Result> { let room_id = self.room_id_for_project(project_id).await?; self.room_transaction(room_id, |tx| async move { let result = project_collaborator::Entity::delete_many() @@ -3342,7 +3396,10 @@ impl Database { .await } - pub async fn get_channels(&self, user_id: UserId) -> Result> { + pub async fn get_channels( + &self, + user_id: UserId, + ) -> Result<(Vec, HashMap>)> { self.transaction(|tx| async move { let tx = tx; @@ -3379,7 +3436,31 @@ impl Database { drop(rows); - Ok(channels) + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryUserIdsAndChannelIds { + ChannelId, + UserId, + } + + let mut participants = room_participant::Entity::find() + .inner_join(room::Entity) + .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id))) + .select_only() + .column(room::Column::ChannelId) + .column(room_participant::Column::UserId) + .into_values::<_, QueryUserIdsAndChannelIds>() + .stream(&*tx) + .await?; + + let mut participant_map: HashMap> = HashMap::default(); + while let Some(row) = participants.next().await { + let row: (ChannelId, UserId) = row?; + participant_map.entry(row.0).or_default().push(row.1) + } + + drop(participants); + + Ok((channels, participant_map)) }) .await } @@ -3523,7 +3604,7 @@ impl Database { .await } - pub async fn get_channel_room(&self, channel_id: ChannelId) -> Result { + pub async fn room_id_for_channel(&self, channel_id: ChannelId) -> Result { self.transaction(|tx| async move { let tx = tx; let room = channel::Model { @@ -3932,29 +4013,19 @@ id_type!(ServerId); id_type!(SignupId); id_type!(UserId); -pub struct ChannelRoom { +#[derive(Clone)] +pub struct JoinRoom { pub room: proto::Room, - pub channel_participants: Vec, -} - -impl Deref for ChannelRoom { - type Target = proto::Room; - - fn deref(&self) -> &Self::Target { - &self.room - } -} - -impl DerefMut for ChannelRoom { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.room - } + pub channel_id: Option, + pub channel_members: Vec, } pub struct RejoinedRoom { - pub room: ChannelRoom, + pub room: proto::Room, pub rejoined_projects: Vec, pub reshared_projects: Vec, + pub channel_id: Option, + pub channel_members: Vec, } pub struct ResharedProject { @@ -3989,14 +4060,18 @@ pub struct RejoinedWorktree { } pub struct LeftRoom { - pub room: ChannelRoom, + pub room: proto::Room, + pub channel_id: Option, + pub channel_members: Vec, pub left_projects: HashMap, pub canceled_calls_to_user_ids: Vec, pub deleted: bool, } pub struct RefreshedRoom { - pub room: ChannelRoom, + pub room: proto::Room, + pub channel_id: Option, + pub channel_members: Vec, pub stale_participant_user_ids: Vec, pub canceled_calls_to_user_ids: Vec, } diff --git a/crates/collab/src/db/room.rs b/crates/collab/src/db/room.rs index 88514ef4f180b203e17185107ec1732837d1e524..c1624f0f2aa98ec255533e1220a4363ea690de0a 100644 --- a/crates/collab/src/db/room.rs +++ b/crates/collab/src/db/room.rs @@ -1,7 +1,7 @@ use super::{ChannelId, RoomId}; use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "rooms")] pub struct Model { #[sea_orm(primary_key)] diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 2ffcef454b631f496b248330345cbdfc73daca03..a6249bb548abd403006d7d9206d66c1148f8d0b1 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -951,7 +951,7 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .await .unwrap(); - let channels = db.get_channels(a_id).await.unwrap(); + let (channels, _) = db.get_channels(a_id).await.unwrap(); assert_eq!( channels, @@ -1047,10 +1047,10 @@ test_both_dbs!( .create_root_channel("channel_1", "1", user_1) .await .unwrap(); - let room_1 = db.get_channel_room(channel_1).await.unwrap(); + let room_1 = db.room_id_for_channel(channel_1).await.unwrap(); // can join a room with membership to its channel - let room = db + let joined_room = db .join_room( room_1, user_1, @@ -1059,9 +1059,9 @@ test_both_dbs!( ) .await .unwrap(); - assert_eq!(room.participants.len(), 1); + assert_eq!(joined_room.room.participants.len(), 1); - drop(room); + drop(joined_room); // cannot join a room without membership to its channel assert!(db .join_room( diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 4d30d174853f55b9f20ccb4ee1ce80ed45606753..59a997377e687dab1816400009343a0b54a9e7d4 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2,7 +2,7 @@ mod connection_pool; use crate::{ auth, - db::{self, ChannelId, ChannelRoom, Database, ProjectId, RoomId, ServerId, User, UserId}, + db::{self, ChannelId, Database, ProjectId, RoomId, ServerId, User, UserId}, executor::Executor, AppState, Result, }; @@ -296,6 +296,15 @@ impl Server { "refreshed room" ); room_updated(&refreshed_room.room, &peer); + if let Some(channel_id) = refreshed_room.channel_id { + channel_updated( + channel_id, + &refreshed_room.room, + &refreshed_room.channel_members, + &peer, + &*pool.lock(), + ); + } contacts_to_update .extend(refreshed_room.stale_participant_user_ids.iter().copied()); contacts_to_update @@ -517,7 +526,7 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, invite_code, channels, channel_invites) = future::try_join4( + let (contacts, invite_code, (channels, channel_participants), channel_invites) = future::try_join4( this.app_state.db.get_contacts(user_id), this.app_state.db.get_invite_code_for_user(user_id), this.app_state.db.get_channels(user_id), @@ -528,7 +537,7 @@ impl Server { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; - this.peer.send(connection_id, build_initial_channels_update(channels, channel_invites))?; + this.peer.send(connection_id, build_initial_channels_update(channels, channel_participants, channel_invites))?; if let Some((code, count)) = invite_code { this.peer.send(connection_id, proto::UpdateInviteInfo { @@ -921,8 +930,8 @@ async fn join_room( .await .join_room(room_id, session.user_id, None, session.connection_id) .await?; - room_updated(&room, &session.peer); - room.clone() + room_updated(&room.room, &session.peer); + room.room.clone() }; for connection_id in session @@ -971,6 +980,9 @@ async fn rejoin_room( response: Response, session: Session, ) -> Result<()> { + let room; + let channel_id; + let channel_members; { let mut rejoined_room = session .db() @@ -1132,6 +1144,21 @@ async fn rejoin_room( )?; } } + + room = mem::take(&mut rejoined_room.room); + channel_id = rejoined_room.channel_id; + channel_members = mem::take(&mut rejoined_room.channel_members); + } + + //TODO: move this into the room guard + if let Some(channel_id) = channel_id { + channel_updated( + channel_id, + &room, + &channel_members, + &session.peer, + &*session.connection_pool().await, + ); } update_user_contacts(session.user_id, &session).await?; @@ -2202,9 +2229,9 @@ async fn invite_channel_member( } async fn remove_channel_member( - request: proto::RemoveChannelMember, - response: Response, - session: Session, + _request: proto::RemoveChannelMember, + _response: Response, + _session: Session, ) -> Result<()> { Ok(()) } @@ -2247,11 +2274,11 @@ async fn join_channel( ) -> Result<()> { let channel_id = ChannelId::from_proto(request.channel_id); - { + let joined_room = { let db = session.db().await; - let room_id = db.get_channel_room(channel_id).await?; + let room_id = db.room_id_for_channel(channel_id).await?; - let room = db + let joined_room = db .join_room( room_id, session.user_id, @@ -2262,7 +2289,10 @@ async fn join_channel( let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| { let token = live_kit - .room_token(&room.live_kit_room, &session.user_id.to_string()) + .room_token( + &joined_room.room.live_kit_room, + &session.user_id.to_string(), + ) .trace_err()?; Some(LiveKitConnectionInfo { @@ -2272,12 +2302,25 @@ async fn join_channel( }); response.send(proto::JoinRoomResponse { - room: Some(room.clone()), + room: Some(joined_room.room.clone()), live_kit_connection_info, })?; - room_updated(&room, &session.peer); - } + room_updated(&joined_room.room, &session.peer); + + joined_room.clone() + }; + + // TODO - do this while still holding the room guard, + // currently there's a possible race condition if someone joins the channel + // after we've dropped the lock but before we finish sending these updates + channel_updated( + channel_id, + &joined_room.room, + &joined_room.channel_members, + &session.peer, + &*session.connection_pool().await, + ); update_user_contacts(session.user_id, &session).await?; @@ -2356,6 +2399,7 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage { fn build_initial_channels_update( channels: Vec, + channel_participants: HashMap>, channel_invites: Vec, ) -> proto::UpdateChannels { let mut update = proto::UpdateChannels::default(); @@ -2426,10 +2470,7 @@ fn contact_for_user( } } -fn room_updated(room: &ChannelRoom, peer: &Peer, pool: &ConnectionPool) { - let channel_ids = &room.channel_participants; - let room = &room.room; - +fn room_updated(room: &proto::Room, peer: &Peer) { broadcast( None, room.participants @@ -2444,17 +2485,41 @@ fn room_updated(room: &ChannelRoom, peer: &Peer, pool: &ConnectionPool) { ) }, ); +} + +fn channel_updated( + channel_id: ChannelId, + room: &proto::Room, + channel_members: &[UserId], + peer: &Peer, + pool: &ConnectionPool, +) { + let participants = room + .participants + .iter() + .map(|p| p.user_id) + .collect::>(); broadcast( None, - channel_ids + channel_members .iter() + .filter(|user_id| { + !room + .participants + .iter() + .any(|p| p.user_id == user_id.to_proto()) + }) .flat_map(|user_id| pool.user_connection_ids(*user_id)), |peer_id| { peer.send( peer_id.into(), - proto::RoomUpdated { - room: Some(room.clone()), + proto::UpdateChannels { + channel_participants: vec![proto::ChannelParticipants { + channel_id: channel_id.to_proto(), + participant_user_ids: participants.clone(), + }], + ..Default::default() }, ) }, @@ -2502,6 +2567,10 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { let canceled_calls_to_user_ids; let live_kit_room; let delete_live_kit_room; + let room; + let channel_members; + let channel_id; + if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? { contacts_to_update.insert(session.user_id); @@ -2509,19 +2578,30 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { project_left(project, session); } - { - let connection_pool = session.connection_pool().await; - room_updated(&left_room.room, &session.peer, &connection_pool); - } - room_id = RoomId::from_proto(left_room.room.id); canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids); live_kit_room = mem::take(&mut left_room.room.live_kit_room); delete_live_kit_room = left_room.deleted; + room = mem::take(&mut left_room.room); + channel_members = mem::take(&mut left_room.channel_members); + channel_id = left_room.channel_id; + + room_updated(&room, &session.peer); } else { return Ok(()); } + // TODO - do this while holding the room guard. + if let Some(channel_id) = channel_id { + channel_updated( + channel_id, + &room, + &channel_members, + &session.peer, + &*session.connection_pool().await, + ); + } + { let pool = session.connection_pool().await; for canceled_user_id in canceled_calls_to_user_ids { diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 14363b74cfa4fbcab64b27c00c51cc142301a405..957e08569350c93af10d327c838bac1668232bac 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -66,7 +66,7 @@ async fn test_basic_channels( ) }); - // Client B now sees that they are in channel A. + // Client B now sees that they are a member channel A. client_b .channel_store() .update(cx_b, |channels, _| { @@ -110,14 +110,20 @@ async fn test_channel_room( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, ) { deterministic.forbid_parking(); let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_b, "user_c").await; let zed_id = server - .make_channel("zed", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .make_channel( + "zed", + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) .await; let active_call_a = cx_a.read(ActiveCall::global); @@ -128,11 +134,26 @@ async fn test_channel_room( .await .unwrap(); + // TODO Test that B and C sees A in the channel room + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: zed_id, + name: "zed".to_string(), + parent_id: None, + depth: 0, + })] + ) + }); + active_call_b .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); + // TODO Test that C sees A and B in the channel room + deterministic.run_until_parked(); let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); @@ -162,12 +183,14 @@ async fn test_channel_room( .await .unwrap(); + // TODO Make sure that C sees A leave + active_call_b .update(cx_b, |active_call, cx| active_call.hang_up(cx)) .await .unwrap(); - // Make sure room exists? + // TODO Make sure that C sees B leave active_call_a .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index f49a879dc7196e3eef2bb77bdc7e52949aead1af..c4fb5aa6532e6d1e1394320edac0986b9d9fada7 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -136,8 +136,8 @@ message Envelope { RemoveChannelMember remove_channel_member = 122; RespondToChannelInvite respond_to_channel_invite = 123; UpdateChannels update_channels = 124; - JoinChannel join_channel = 125; - RemoveChannel remove_channel = 126; + JoinChannel join_channel = 126; + RemoveChannel remove_channel = 127; } } @@ -870,6 +870,12 @@ message UpdateChannels { repeated uint64 remove_channels = 2; repeated Channel channel_invitations = 3; repeated uint64 remove_channel_invitations = 4; + repeated ChannelParticipants channel_participants = 5; +} + +message ChannelParticipants { + uint64 channel_id = 1; + repeated uint64 participant_user_ids = 2; } message JoinChannel { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index f6985d69063a2aa63f15d81cfee14434ccee5d22..07d54ce4db38381f014fe1ee77328010adabe1f8 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -244,7 +244,7 @@ messages!( (UpdateWorktreeSettings, Foreground), (UpdateDiffBase, Foreground), (GetPrivateUserInfo, Foreground), - (GetPrivateUserInfoResponse, Foreground), + (GetPrivateUserInfoResponse, Foreground) ); request_messages!( From fca8cdcb8e10a922005a9bd96b625fab55709e40 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 2 Aug 2023 15:09:37 -0700 Subject: [PATCH 028/105] Start work on rendering channel participants in collab panel Co-authored-by: mikayla --- crates/client/src/channel_store.rs | 56 +++++++++++++- crates/collab/src/db.rs | 20 ----- crates/collab/src/rpc.rs | 15 ++-- crates/collab/src/tests.rs | 3 + crates/collab/src/tests/channel_tests.rs | 94 ++++++++++++++++++++++-- crates/collab_ui/src/face_pile.rs | 34 ++++----- crates/collab_ui/src/panel.rs | 28 +++++-- crates/theme/src/theme.rs | 1 + styles/src/style_tree/collab_panel.ts | 1 + 9 files changed, 192 insertions(+), 60 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 5218c56891461820711407d17cd80fa44e783e1a..558570475e636508dd8d5ae32ce9693a69c80786 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -7,11 +7,12 @@ use rpc::{proto, TypedEnvelope}; use std::sync::Arc; type ChannelId = u64; +type UserId = u64; pub struct ChannelStore { channels: Vec>, channel_invitations: Vec>, - channel_participants: HashMap>, + channel_participants: HashMap>>, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, @@ -60,6 +61,12 @@ impl ChannelStore { self.channels.iter().find(|c| c.id == channel_id).cloned() } + pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc] { + self.channel_participants + .get(&channel_id) + .map_or(&[], |v| v.as_slice()) + } + pub fn create_channel( &self, name: &str, @@ -78,7 +85,7 @@ impl ChannelStore { pub fn invite_member( &self, channel_id: ChannelId, - user_id: u64, + user_id: UserId, admin: bool, ) -> impl Future> { let client = self.client.clone(); @@ -162,6 +169,8 @@ impl ChannelStore { .retain(|channel| !payload.remove_channels.contains(&channel.id)); self.channel_invitations .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); + self.channel_participants + .retain(|channel_id, _| !payload.remove_channels.contains(channel_id)); for channel in payload.channel_invitations { if let Some(existing_channel) = self @@ -215,6 +224,49 @@ impl ChannelStore { ); } } + + let mut all_user_ids = Vec::new(); + let channel_participants = payload.channel_participants; + for entry in &channel_participants { + for user_id in entry.participant_user_ids.iter() { + if let Err(ix) = all_user_ids.binary_search(user_id) { + all_user_ids.insert(ix, *user_id); + } + } + } + + // TODO: Race condition if an update channels messages comes in while resolving avatars + let users = self + .user_store + .update(cx, |user_store, cx| user_store.get_users(all_user_ids, cx)); + cx.spawn(|this, mut cx| async move { + let users = users.await?; + + this.update(&mut cx, |this, cx| { + for entry in &channel_participants { + let mut participants: Vec<_> = entry + .participant_user_ids + .iter() + .filter_map(|user_id| { + users + .binary_search_by_key(&user_id, |user| &user.id) + .ok() + .map(|ix| users[ix].clone()) + }) + .collect(); + + participants.sort_by_key(|u| u.id); + + this.channel_participants + .insert(entry.channel_id, participants); + } + + cx.notify(); + }); + anyhow::Ok(()) + }) + .detach(); + cx.notify(); } } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index ad87266e7ddad7fad8c70ddc69c6c3d5c567d175..85f5d5f0b86b006e4d0f1939a667654d48076d9e 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -2200,26 +2200,6 @@ impl Database { )) } - async fn get_channel_members_for_room( - &self, - room_id: RoomId, - tx: &DatabaseTransaction, - ) -> Result> { - let db_room = room::Model { - id: room_id, - ..Default::default() - }; - - let channel_users = - if let Some(channel) = db_room.find_related(channel::Entity).one(tx).await? { - self.get_channel_members_internal(channel.id, tx).await? - } else { - Vec::new() - }; - - Ok(channel_users) - } - // projects pub async fn project_count_excluding_admins(&self) -> Result { diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 59a997377e687dab1816400009343a0b54a9e7d4..526f12d812b41dd0f81cc026daa2c722670a596f 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2412,6 +2412,15 @@ fn build_initial_channels_update( }); } + for (channel_id, participants) in channel_participants { + update + .channel_participants + .push(proto::ChannelParticipants { + channel_id: channel_id.to_proto(), + participant_user_ids: participants.into_iter().map(|id| id.to_proto()).collect(), + }); + } + for channel in channel_invites { update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), @@ -2504,12 +2513,6 @@ fn channel_updated( None, channel_members .iter() - .filter(|user_id| { - !room - .participants - .iter() - .any(|p| p.user_id == user_id.to_proto()) - }) .flat_map(|user_id| pool.user_connection_ids(*user_id)), |peer_id| { peer.send( diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index e0346dbe7f289feec118aeebf09b12dbeeea786e..26ca5a008efc4bb5b37bd6eb9d39634b8d5e4973 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -103,6 +103,9 @@ impl TestServer { async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient { cx.update(|cx| { + if cx.has_global::() { + panic!("Same cx used to create two test clients") + } cx.set_global(SettingsStore::test(cx)); }); diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 957e08569350c93af10d327c838bac1668232bac..c41ac84d1d310a9cc623c05751a7d96eda0ad1d7 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,5 +1,5 @@ use call::ActiveCall; -use client::Channel; +use client::{Channel, User}; use gpui::{executor::Deterministic, TestAppContext}; use std::sync::Arc; @@ -26,6 +26,7 @@ async fn test_basic_channels( .await .unwrap(); + deterministic.run_until_parked(); client_a.channel_store().read_with(cx_a, |channels, _| { assert_eq!( channels.channels(), @@ -105,6 +106,13 @@ async fn test_basic_channels( .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); } +fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u64]) { + assert_eq!( + participants.iter().map(|p| p.id).collect::>(), + expected_partitipants + ); +} + #[gpui::test] async fn test_channel_room( deterministic: Arc, @@ -116,7 +124,7 @@ async fn test_channel_room( let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; - let client_c = server.create_client(cx_b, "user_c").await; + let client_c = server.create_client(cx_c, "user_c").await; let zed_id = server .make_channel( @@ -134,8 +142,21 @@ async fn test_channel_room( .await .unwrap(); - // TODO Test that B and C sees A in the channel room + // Give everyone a chance to observe user A joining + deterministic.run_until_parked(); + + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap()], + ); + }); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap()], + ); assert_eq!( channels.channels(), &[Arc::new(Channel { @@ -147,15 +168,41 @@ async fn test_channel_room( ) }); + client_c.channel_store().read_with(cx_c, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap()], + ); + }); + active_call_b .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); - // TODO Test that C sees A and B in the channel room - deterministic.run_until_parked(); + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap(), client_b.user_id().unwrap()], + ); + }); + + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap(), client_b.user_id().unwrap()], + ); + }); + + client_c.channel_store().read_with(cx_c, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap(), client_b.user_id().unwrap()], + ); + }); + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); room_a.read_with(cx_a, |room, _| assert!(room.is_connected())); assert_eq!( @@ -183,14 +230,47 @@ async fn test_channel_room( .await .unwrap(); - // TODO Make sure that C sees A leave + deterministic.run_until_parked(); + + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_b.user_id().unwrap()], + ); + }); + + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_b.user_id().unwrap()], + ); + }); + + client_c.channel_store().read_with(cx_c, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_b.user_id().unwrap()], + ); + }); active_call_b .update(cx_b, |active_call, cx| active_call.hang_up(cx)) .await .unwrap(); - // TODO Make sure that C sees B leave + deterministic.run_until_parked(); + + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq(channels.channel_participants(zed_id), &[]); + }); + + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_participants_eq(channels.channel_participants(zed_id), &[]); + }); + + client_c.channel_store().read_with(cx_c, |channels, _| { + assert_participants_eq(channels.channel_participants(zed_id), &[]); + }); active_call_a .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs index 1bbceee9af1bec406bf9b1398fde94dd230ac73d..7e95a7677ca6ab92912305aa5ac1cc36c9924061 100644 --- a/crates/collab_ui/src/face_pile.rs +++ b/crates/collab_ui/src/face_pile.rs @@ -7,34 +7,34 @@ use gpui::{ }, json::ToJson, serde_json::{self, json}, - AnyElement, Axis, Element, LayoutContext, SceneBuilder, ViewContext, + AnyElement, Axis, Element, LayoutContext, SceneBuilder, View, ViewContext, }; use crate::CollabTitlebarItem; -pub(crate) struct FacePile { +pub(crate) struct FacePile { overlap: f32, - faces: Vec>, + faces: Vec>, } -impl FacePile { - pub fn new(overlap: f32) -> FacePile { - FacePile { +impl FacePile { + pub fn new(overlap: f32) -> Self { + Self { overlap, faces: Vec::new(), } } } -impl Element for FacePile { +impl Element for FacePile { type LayoutState = (); type PaintState = (); fn layout( &mut self, constraint: gpui::SizeConstraint, - view: &mut CollabTitlebarItem, - cx: &mut LayoutContext, + view: &mut V, + cx: &mut LayoutContext, ) -> (Vector2F, Self::LayoutState) { debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY); @@ -53,8 +53,8 @@ impl Element for FacePile { bounds: RectF, visible_bounds: RectF, _layout: &mut Self::LayoutState, - view: &mut CollabTitlebarItem, - cx: &mut ViewContext, + view: &mut V, + cx: &mut ViewContext, ) -> Self::PaintState { let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); @@ -80,8 +80,8 @@ impl Element for FacePile { _: RectF, _: &Self::LayoutState, _: &Self::PaintState, - _: &CollabTitlebarItem, - _: &ViewContext, + _: &V, + _: &ViewContext, ) -> Option { None } @@ -91,8 +91,8 @@ impl Element for FacePile { bounds: RectF, _: &Self::LayoutState, _: &Self::PaintState, - _: &CollabTitlebarItem, - _: &ViewContext, + _: &V, + _: &ViewContext, ) -> serde_json::Value { json!({ "type": "FacePile", @@ -101,8 +101,8 @@ impl Element for FacePile { } } -impl Extend> for FacePile { - fn extend>>(&mut self, children: T) { +impl Extend> for FacePile { + fn extend>>(&mut self, children: T) { self.faces.extend(children); } } diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 1973ddd9f6d492130ca03db0e5bde0976d5ce551..406daae0f22cf37d57036e5c36f638a2a126a88b 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -40,6 +40,8 @@ use workspace::{ Workspace, }; +use crate::face_pile::FacePile; + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { channel_id: u64, @@ -253,7 +255,7 @@ impl CollabPanel { ) } ListEntry::Channel(channel) => { - Self::render_channel(&*channel, &theme.collab_panel, is_selected, cx) + this.render_channel(&*channel, &theme.collab_panel, is_selected, cx) } ListEntry::ChannelInvite(channel) => Self::render_channel_invite( channel.clone(), @@ -1265,20 +1267,16 @@ impl CollabPanel { } fn render_channel( + &self, channel: &Channel, theme: &theme::CollabPanel, is_selected: bool, cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; - MouseEventHandler::::new(channel.id as usize, cx, |state, _cx| { + MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() - .with_child({ - Svg::new("icons/file_icons/hash.svg") - // .with_style(theme.contact_avatar) - .aligned() - .left() - }) + .with_child({ Svg::new("icons/file_icons/hash.svg").aligned().left() }) .with_child( Label::new(channel.name.clone(), theme.contact_username.text.clone()) .contained() @@ -1287,6 +1285,20 @@ impl CollabPanel { .left() .flex(1., true), ) + .with_child( + FacePile::new(theme.face_overlap).with_children( + self.channel_store + .read(cx) + .channel_participants(channel_id) + .iter() + .filter_map(|user| { + Some( + Image::from_data(user.avatar.clone()?) + .with_style(theme.contact_avatar), + ) + }), + ), + ) .constrained() .with_height(theme.row_height) .contained() diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 3de878118e6e0be365ecbeec034fa78445ad9758..96eac81a504669c3a03d13c67e9ccd2f10da8874 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -241,6 +241,7 @@ pub struct CollabPanel { pub disabled_button: IconButton, pub section_icon_size: f32, pub calling_indicator: ContainedText, + pub face_overlap: f32, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 8e817add3ffa39f0df0761726f9dfc7a0bd9baaf..49a343e6c9384e77cbdf23a5bf29aefa366346ee 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -275,5 +275,6 @@ export default function contacts_panel(): any { }, }, }), + face_overlap: 8 } } From 4d551104522ddfcc1ed4c597ed56ea1f7d3beb13 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 2 Aug 2023 15:45:19 -0700 Subject: [PATCH 029/105] Restore seeding of random GH users in seed-db Co-authored-by: Mikayla --- crates/collab/src/bin/seed.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index 9384e826c0e1ac65b7f39aaa481320969d1fd7b8..cb1594e941a0ebb1735d77c258fb9b4706880bde 100644 --- a/crates/collab/src/bin/seed.rs +++ b/crates/collab/src/bin/seed.rs @@ -64,9 +64,9 @@ async fn main() { .expect("failed to fetch user") .is_none() { - if let Some(email) = &github_user.email { + if admin { db.create_user( - email, + &format!("{}@zed.dev", github_user.login), admin, db::NewUserParams { github_login: github_user.login, @@ -76,15 +76,11 @@ async fn main() { ) .await .expect("failed to insert user"); - } else if admin { - db.create_user( - &format!("{}@zed.dev", github_user.login), - admin, - db::NewUserParams { - github_login: github_user.login, - github_user_id: github_user.id, - invite_count: 5, - }, + } else { + db.get_or_create_user_by_github_account( + &github_user.login, + Some(github_user.id), + github_user.email.as_deref(), ) .await .expect("failed to insert user"); From 0ae1f29be82c5b5a81cb9a7c62a42b6985a46dce Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 2 Aug 2023 15:52:56 -0700 Subject: [PATCH 030/105] wip --- crates/client/src/channel_store.rs | 12 ++++-------- crates/collab/src/db/tests.rs | 2 +- crates/collab_ui/src/face_pile.rs | 2 -- crates/collab_ui/src/panel.rs | 17 ++++++----------- crates/collab_ui/src/panel/channel_modal.rs | 8 ++++---- script/zed-with-local-servers | 5 ++++- styles/.eslintrc.js | 1 + styles/src/style_tree/collab_panel.ts | 1 + styles/tsconfig.json | 4 +++- 9 files changed, 24 insertions(+), 28 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 558570475e636508dd8d5ae32ce9693a69c80786..534bd0b05a544fbc83f8f1f7eff437a1634d839c 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -115,10 +115,6 @@ impl ChannelStore { } } - pub fn is_channel_invite_pending(&self, channel: &Arc) -> bool { - false - } - pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future> { let client = self.client.clone(); async move { @@ -127,6 +123,10 @@ impl ChannelStore { } } + pub fn is_channel_invite_pending(&self, _: &Arc) -> bool { + false + } + pub fn remove_member( &self, channel_id: ChannelId, @@ -144,10 +144,6 @@ impl ChannelStore { todo!() } - pub fn add_guest_channel(&self, channel_id: ChannelId) -> Task> { - todo!() - } - async fn handle_update_channels( this: ModelHandle, message: TypedEnvelope, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index a6249bb548abd403006d7d9206d66c1148f8d0b1..a1d1a23dc96e36f6d662c7d20db06fbecf48ea22 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -1080,7 +1080,7 @@ test_both_dbs!( test_channel_invites_sqlite, db, { - let owner_id = db.create_server("test").await.unwrap().0 as u32; + db.create_server("test").await.unwrap(); let user_1 = db .create_user( diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs index 7e95a7677ca6ab92912305aa5ac1cc36c9924061..30fcb9750678b24ecb949341d9209e6489ff21e4 100644 --- a/crates/collab_ui/src/face_pile.rs +++ b/crates/collab_ui/src/face_pile.rs @@ -10,8 +10,6 @@ use gpui::{ AnyElement, Axis, Element, LayoutContext, SceneBuilder, View, ViewContext, }; -use crate::CollabTitlebarItem; - pub(crate) struct FacePile { overlap: f32, faces: Vec>, diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 406daae0f22cf37d57036e5c36f638a2a126a88b..667e8d3a5cdec6fc9ad17055ef3cfbafd0b3dfcb 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -1259,8 +1259,8 @@ impl CollabPanel { fn render_channel_editor( &self, - theme: &theme::CollabPanel, - depth: usize, + _theme: &theme::CollabPanel, + _depth: usize, cx: &AppContext, ) -> AnyElement { ChildView::new(&self.channel_name_editor, cx).into_any() @@ -1276,7 +1276,7 @@ impl CollabPanel { let channel_id = channel.id; MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() - .with_child({ Svg::new("icons/file_icons/hash.svg").aligned().left() }) + .with_child(Svg::new("icons/file_icons/hash.svg").aligned().left()) .with_child( Label::new(channel.name.clone(), theme.contact_username.text.clone()) .contained() @@ -1329,12 +1329,7 @@ impl CollabPanel { let button_spacing = theme.contact_button_spacing; Flex::row() - .with_child({ - Svg::new("icons/file_icons/hash.svg") - // .with_style(theme.contact_avatar) - .aligned() - .left() - }) + .with_child(Svg::new("icons/file_icons/hash.svg").aligned().left()) .with_child( Label::new(channel.name.clone(), theme.contact_username.text.clone()) .contained() @@ -1616,7 +1611,7 @@ impl CollabPanel { } } } else if let Some((editing_state, channel_name)) = self.take_editing_state(cx) { - let create_channel = self.channel_store.update(cx, |channel_store, cx| { + let create_channel = self.channel_store.update(cx, |channel_store, _| { channel_store.create_channel(&channel_name, editing_state.parent_id) }); @@ -1687,7 +1682,7 @@ impl CollabPanel { cx.spawn(|_, mut cx| async move { if answer.next().await == Some(0) { if let Err(e) = channel_store - .update(&mut cx, |channels, cx| channels.remove_channel(channel_id)) + .update(&mut cx, |channels, _| channels.remove_channel(channel_id)) .await { cx.prompt( diff --git a/crates/collab_ui/src/panel/channel_modal.rs b/crates/collab_ui/src/panel/channel_modal.rs index fff1dc86244d8b76e62778f32eae3447b420b6d8..aa1b3e5a136887cdd045d756f62d4bd7b3a9f5eb 100644 --- a/crates/collab_ui/src/panel/channel_modal.rs +++ b/crates/collab_ui/src/panel/channel_modal.rs @@ -9,7 +9,7 @@ pub fn init(cx: &mut AppContext) { pub struct ChannelModal { has_focus: bool, - input_editor: ViewHandle, + filter_editor: ViewHandle, } pub enum Event { @@ -30,7 +30,7 @@ impl ChannelModal { ChannelModal { has_focus: false, - input_editor, + filter_editor: input_editor, } } @@ -55,7 +55,7 @@ impl View for ChannelModal { enum ChannelModal {} MouseEventHandler::::new(0, cx, |_, cx| { Flex::column() - .with_child(ChildView::new(self.input_editor.as_any(), cx)) + .with_child(ChildView::new(self.filter_editor.as_any(), cx)) .with_child(Label::new("ADD OR BROWSE CHANNELS HERE", style)) .contained() .with_style(modal_container) @@ -71,7 +71,7 @@ impl View for ChannelModal { fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { self.has_focus = true; if cx.is_self_focused() { - cx.focus(&self.input_editor); + cx.focus(&self.filter_editor); } } diff --git a/script/zed-with-local-servers b/script/zed-with-local-servers index c47b0e3de0126318ab6c0556582357cd700c2f17..e1b224de600cea9c961978920f01921896ca4f51 100755 --- a/script/zed-with-local-servers +++ b/script/zed-with-local-servers @@ -1,3 +1,6 @@ #!/bin/bash -ZED_ADMIN_API_TOKEN=secret ZED_IMPERSONATE=as-cii ZED_SERVER_URL=http://localhost:8080 cargo run $@ +: "${ZED_IMPERSONATE:=as-cii}" +export ZED_IMPERSONATE + +ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:8080 cargo run $@ diff --git a/styles/.eslintrc.js b/styles/.eslintrc.js index 485ff73d10441b93e118bbf1a6a89ba5a5f3a8d1..82e9636189a40755fa3e9f083e4ebddd4845330a 100644 --- a/styles/.eslintrc.js +++ b/styles/.eslintrc.js @@ -28,6 +28,7 @@ module.exports = { }, rules: { "linebreak-style": ["error", "unix"], + "@typescript-eslint/no-explicit-any": "off", semi: ["error", "never"], }, } diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 49a343e6c9384e77cbdf23a5bf29aefa366346ee..3390dd51f8e4052edbb969d3fbdb5e85bb6a1b6f 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -8,6 +8,7 @@ import { import { interactive, toggleable } from "../element" import { useTheme } from "../theme" + export default function contacts_panel(): any { const theme = useTheme() diff --git a/styles/tsconfig.json b/styles/tsconfig.json index a1913027b705df7d349c78bcc6b74bb96851fe8c..281bd74b215bd16426bb6a8f9d68ddeb5a5bea43 100644 --- a/styles/tsconfig.json +++ b/styles/tsconfig.json @@ -24,5 +24,7 @@ "useUnknownInCatchVariables": false, "baseUrl": "." }, - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } From 30e1bfc872bf88214356400c0774ba921174b9d7 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 2 Aug 2023 17:13:09 -0700 Subject: [PATCH 031/105] Add the ability to jump between channels while in a channel --- crates/call/src/call.rs | 6 +++ crates/client/src/client.rs | 6 ++- crates/collab/src/db.rs | 29 +++++++++++++++ crates/collab/src/rpc.rs | 25 ++++++++++++- crates/collab/src/tests/channel_tests.rs | 47 ++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 3 deletions(-) diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 3cd868a438daf607c75022e6963f0b48f067a389..6e58be4f153b04b1df4a313c0861186ef9d98dbf 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -279,15 +279,21 @@ impl ActiveCall { channel_id: u64, cx: &mut ModelContext, ) -> Task> { + let leave_room; if let Some(room) = self.room().cloned() { if room.read(cx).channel_id() == Some(channel_id) { return Task::ready(Ok(())); + } else { + leave_room = room.update(cx, |room, cx| room.leave(cx)); } + } else { + leave_room = Task::ready(Ok(())); } let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx); cx.spawn(|this, mut cx| async move { + leave_room.await?; let room = join.await?; this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx)) .await?; diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 1e86cef4cca81c882d64475c406c1d76db3a6417..8ef3e32ea8f98b47a744e148f881289934fae215 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -540,6 +540,7 @@ impl Client { } } + #[track_caller] pub fn add_message_handler( self: &Arc, model: ModelHandle, @@ -575,8 +576,11 @@ impl Client { }), ); if prev_handler.is_some() { + let location = std::panic::Location::caller(); panic!( - "registered handler for the same message {} twice", + "{}:{} registered handler for the same message {} twice", + location.file(), + location.line(), std::any::type_name::() ); } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 85f5d5f0b86b006e4d0f1939a667654d48076d9e..36b226b97b340df4b7eca7d1e570f70ec2b98d89 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1342,6 +1342,35 @@ impl Database { .await } + pub async fn is_current_room_different_channel( + &self, + user_id: UserId, + channel_id: ChannelId, + ) -> Result { + self.transaction(|tx| async move { + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryAs { + ChannelId, + } + + let channel_id_model: Option = room_participant::Entity::find() + .select_only() + .column_as(room::Column::ChannelId, QueryAs::ChannelId) + .inner_join(room::Entity) + .filter(room_participant::Column::UserId.eq(user_id)) + .into_values::<_, QueryAs>() + .one(&*tx) + .await?; + + let result = channel_id_model + .map(|channel_id_model| channel_id_model != channel_id) + .unwrap_or(false); + + Ok(result) + }) + .await + } + pub async fn join_room( &self, room_id: RoomId, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 526f12d812b41dd0f81cc026daa2c722670a596f..15237049c39f0c6a2fe9e4818196bba5ad5803ef 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2276,6 +2276,14 @@ async fn join_channel( let joined_room = { let db = session.db().await; + + if db + .is_current_room_different_channel(session.user_id, channel_id) + .await? + { + leave_room_for_session_with_guard(&session, &db).await?; + } + let room_id = db.room_id_for_channel(channel_id).await?; let joined_room = db @@ -2531,6 +2539,14 @@ fn channel_updated( async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> { let db = session.db().await; + update_user_contacts_with_guard(user_id, session, &db).await +} + +async fn update_user_contacts_with_guard( + user_id: UserId, + session: &Session, + db: &DbHandle, +) -> Result<()> { let contacts = db.get_contacts(user_id).await?; let busy = db.is_user_busy(user_id).await?; @@ -2564,6 +2580,11 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> } async fn leave_room_for_session(session: &Session) -> Result<()> { + let db = session.db().await; + leave_room_for_session_with_guard(session, &db).await +} + +async fn leave_room_for_session_with_guard(session: &Session, db: &DbHandle) -> Result<()> { let mut contacts_to_update = HashSet::default(); let room_id; @@ -2574,7 +2595,7 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { let channel_members; let channel_id; - if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? { + if let Some(mut left_room) = db.leave_room(session.connection_id).await? { contacts_to_update.insert(session.user_id); for project in left_room.left_projects.values() { @@ -2624,7 +2645,7 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { } for contact_user_id in contacts_to_update { - update_user_contacts(contact_user_id, &session).await?; + update_user_contacts_with_guard(contact_user_id, &session, db).await?; } if let Some(live_kit) = session.live_kit_client.as_ref() { diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index c41ac84d1d310a9cc623c05751a7d96eda0ad1d7..39997405572d11c4fb179aeada73de7cd30cd98d 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -304,3 +304,50 @@ async fn test_channel_room( } ); } + +#[gpui::test] +async fn test_channel_jumping(deterministic: Arc, cx_a: &mut TestAppContext) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + + let zed_id = server.make_channel("zed", (&client_a, cx_a), &mut []).await; + let rust_id = server + .make_channel("rust", (&client_a, cx_a), &mut []) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + + active_call_a + .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) + .await + .unwrap(); + + // Give everything a chance to observe user A joining + deterministic.run_until_parked(); + + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq( + channels.channel_participants(zed_id), + &[client_a.user_id().unwrap()], + ); + assert_participants_eq(channels.channel_participants(rust_id), &[]); + }); + + active_call_a + .update(cx_a, |active_call, cx| { + active_call.join_channel(rust_id, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq(channels.channel_participants(zed_id), &[]); + assert_participants_eq( + channels.channel_participants(rust_id), + &[client_a.user_id().unwrap()], + ); + }); +} From d450c4be9a0c051c40041d1ba803fc229e215d4f Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 3 Aug 2023 10:59:09 -0700 Subject: [PATCH 032/105] WIP: add custom channel modal --- crates/client/src/channel_store.rs | 4 +-- crates/collab_ui/src/panel.rs | 23 ++++++++++++- crates/collab_ui/src/panel/channel_modal.rs | 36 ++++++++++++++++++--- crates/theme/src/theme.rs | 6 ++++ styles/src/style_tree/channel_modal.ts | 9 ++++++ styles/src/style_tree/collab_panel.ts | 2 ++ 6 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 styles/src/style_tree/channel_modal.ts diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 534bd0b05a544fbc83f8f1f7eff437a1634d839c..1d3ed24d1b4bdbe8846fe6100f1e706d93fe58ab 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -6,8 +6,8 @@ use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, TypedEnvelope}; use std::sync::Arc; -type ChannelId = u64; -type UserId = u64; +pub type ChannelId = u64; +pub type UserId = u64; pub struct ChannelStore { channels: Vec>, diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/panel.rs index 667e8d3a5cdec6fc9ad17055ef3cfbafd0b3dfcb..4092351a75344a12e238a112a6ee21f1ce82d9a3 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/panel.rs @@ -42,6 +42,8 @@ use workspace::{ use crate::face_pile::FacePile; +use self::channel_modal::ChannelModal; + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { channel_id: u64, @@ -52,9 +54,14 @@ struct NewChannel { channel_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct AddMember { + channel_id: u64, +} + actions!(collab_panel, [ToggleFocus]); -impl_actions!(collab_panel, [RemoveChannel, NewChannel]); +impl_actions!(collab_panel, [RemoveChannel, NewChannel, AddMember]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; @@ -69,6 +76,7 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::confirm); cx.add_action(CollabPanel::remove_channel); cx.add_action(CollabPanel::new_subchannel); + cx.add_action(CollabPanel::add_member); } #[derive(Debug, Default)] @@ -1506,6 +1514,7 @@ impl CollabPanel { vec![ ContextMenuItem::action("New Channel", NewChannel { channel_id }), ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), + ContextMenuItem::action("Add member", AddMember { channel_id }), ], cx, ); @@ -1668,6 +1677,18 @@ impl CollabPanel { cx.notify(); } + fn add_member(&mut self, action: &AddMember, cx: &mut ViewContext) { + if let Some(workspace) = self.workspace.upgrade(cx) { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(cx, |_, cx| { + cx.add_view(|cx| { + ChannelModal::new(action.channel_id, self.channel_store.clone(), cx) + }) + }) + }); + } + } + fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { let channel_id = action.channel_id; let channel_store = self.channel_store.clone(); diff --git a/crates/collab_ui/src/panel/channel_modal.rs b/crates/collab_ui/src/panel/channel_modal.rs index aa1b3e5a136887cdd045d756f62d4bd7b3a9f5eb..96424114c798c3e42bc8f5b7d41e211a6b668787 100644 --- a/crates/collab_ui/src/panel/channel_modal.rs +++ b/crates/collab_ui/src/panel/channel_modal.rs @@ -1,5 +1,8 @@ +use client::{ChannelId, ChannelStore}; use editor::Editor; -use gpui::{elements::*, AnyViewHandle, AppContext, Entity, View, ViewContext, ViewHandle}; +use gpui::{ + elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, View, ViewContext, ViewHandle, +}; use menu::Cancel; use workspace::{item::ItemHandle, Modal}; @@ -10,6 +13,10 @@ pub fn init(cx: &mut AppContext) { pub struct ChannelModal { has_focus: bool, filter_editor: ViewHandle, + selection: usize, + list_state: ListState, + channel_store: ModelHandle, + channel_id: ChannelId, } pub enum Event { @@ -21,16 +28,28 @@ impl Entity for ChannelModal { } impl ChannelModal { - pub fn new(cx: &mut ViewContext) -> Self { + pub fn new( + channel_id: ChannelId, + channel_store: ModelHandle, + cx: &mut ViewContext, + ) -> Self { let input_editor = cx.add_view(|cx| { let mut editor = Editor::single_line(None, cx); - editor.set_placeholder_text("Create or add a channel", cx); + editor.set_placeholder_text("Add a member", cx); editor }); + let list_state = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { + Empty::new().into_any() + }); + ChannelModal { has_focus: false, filter_editor: input_editor, + selection: 0, + list_state, + channel_id, + channel_store, } } @@ -49,14 +68,21 @@ impl View for ChannelModal { } fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { - let style = theme::current(cx).editor.hint_diagnostic.message.clone(); + let theme = theme::current(cx).clone(); + let style = &theme.collab_panel.modal; let modal_container = theme::current(cx).picker.container.clone(); enum ChannelModal {} MouseEventHandler::::new(0, cx, |_, cx| { Flex::column() .with_child(ChildView::new(self.filter_editor.as_any(), cx)) - .with_child(Label::new("ADD OR BROWSE CHANNELS HERE", style)) + .with_child( + List::new(self.list_state.clone()) + .constrained() + .with_width(style.width) + .flex(1., true) + .into_any(), + ) .contained() .with_style(modal_container) .constrained() diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 96eac81a504669c3a03d13c67e9ccd2f10da8874..8f0ceeab883cfd55ec7cbbee7861ed7c181059a6 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,6 +220,7 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, + pub modal: ChannelModal, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, pub leave_call_button: IconButton, @@ -244,6 +245,11 @@ pub struct CollabPanel { pub face_overlap: f32, } +#[derive(Deserialize, Default, JsonSchema)] +pub struct ChannelModal { + pub width: f32, +} + #[derive(Deserialize, Default, JsonSchema)] pub struct ProjectRow { #[serde(flatten)] diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts new file mode 100644 index 0000000000000000000000000000000000000000..95ae337cbc64c2d738c802d936e3705c0b3f28e6 --- /dev/null +++ b/styles/src/style_tree/channel_modal.ts @@ -0,0 +1,9 @@ +import { useTheme } from "../theme" + +export default function contacts_panel(): any { + const theme = useTheme() + + return { + width: 100, + } +} diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 3390dd51f8e4052edbb969d3fbdb5e85bb6a1b6f..37145d0c46b63e8ddbe14dd18964abbfd6919055 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -7,6 +7,7 @@ import { } from "./components" import { interactive, toggleable } from "../element" import { useTheme } from "../theme" +import channel_modal from "./channel_modal" export default function contacts_panel(): any { @@ -51,6 +52,7 @@ export default function contacts_panel(): any { } return { + modal: channel_modal(), background: background(layer), padding: { top: 12, From 6c4964f0710b0a0c51ffec138e8f3d1df05175a2 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 3 Aug 2023 11:40:55 -0700 Subject: [PATCH 033/105] WIP: continue channel management modal and rename panel to collab_panel --- crates/client/src/channel_store.rs | 25 +++ .../src/{panel.rs => collab_panel.rs} | 9 +- .../src/collab_panel/channel_modal.rs | 178 ++++++++++++++++++ .../{panel => collab_panel}/contact_finder.rs | 0 .../{panel => collab_panel}/panel_settings.rs | 0 crates/collab_ui/src/collab_ui.rs | 4 +- crates/collab_ui/src/panel/channel_modal.rs | 119 ------------ crates/rpc/proto/zed.proto | 10 + crates/rpc/src/proto.rs | 5 +- crates/theme/src/theme.rs | 9 +- crates/zed/src/zed.rs | 6 +- styles/src/style_tree/channel_modal.ts | 67 ++++++- styles/src/style_tree/collab_panel.ts | 2 +- 13 files changed, 303 insertions(+), 131 deletions(-) rename crates/collab_ui/src/{panel.rs => collab_panel.rs} (99%) create mode 100644 crates/collab_ui/src/collab_panel/channel_modal.rs rename crates/collab_ui/src/{panel => collab_panel}/contact_finder.rs (100%) rename crates/collab_ui/src/{panel => collab_panel}/panel_settings.rs (100%) delete mode 100644 crates/collab_ui/src/panel/channel_modal.rs diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 1d3ed24d1b4bdbe8846fe6100f1e706d93fe58ab..fcd0083c3b76616afc9c80334ea32ed131ff24a7 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -30,6 +30,11 @@ impl Entity for ChannelStore { type Event = (); } +pub enum ChannelMemberStatus { + Invited, + Member, +} + impl ChannelStore { pub fn new( client: Arc, @@ -115,6 +120,26 @@ impl ChannelStore { } } + pub fn get_channel_members( + &self, + channel_id: ChannelId, + ) -> impl Future>> { + let client = self.client.clone(); + async move { + let response = client + .request(proto::GetChannelMembers { channel_id }) + .await?; + let mut result = HashMap::default(); + for member_id in response.members { + result.insert(member_id, ChannelMemberStatus::Member); + } + for invitee_id in response.invited_members { + result.insert(invitee_id, ChannelMemberStatus::Invited); + } + Ok(result) + } + } + pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future> { let client = self.client.clone(); async move { diff --git a/crates/collab_ui/src/panel.rs b/crates/collab_ui/src/collab_panel.rs similarity index 99% rename from crates/collab_ui/src/panel.rs rename to crates/collab_ui/src/collab_panel.rs index 4092351a75344a12e238a112a6ee21f1ce82d9a3..daad527979c5e32846f4a63b562e52a5011a4d86 100644 --- a/crates/collab_ui/src/panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -42,7 +42,7 @@ use workspace::{ use crate::face_pile::FacePile; -use self::channel_modal::ChannelModal; +use self::channel_modal::build_channel_modal; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { @@ -1682,7 +1682,12 @@ impl CollabPanel { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(cx, |_, cx| { cx.add_view(|cx| { - ChannelModal::new(action.channel_id, self.channel_store.clone(), cx) + build_channel_modal( + self.user_store.clone(), + self.channel_store.clone(), + action.channel_id, + cx, + ) }) }) }); diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs new file mode 100644 index 0000000000000000000000000000000000000000..0cf24dbaf5025a4a2658480ada2a066ea37275cd --- /dev/null +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -0,0 +1,178 @@ +use client::{ + ChannelId, ChannelMemberStatus, ChannelStore, ContactRequestStatus, User, UserId, UserStore, +}; +use collections::HashMap; +use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext}; +use picker::{Picker, PickerDelegate, PickerEvent}; +use std::sync::Arc; +use util::TryFutureExt; + +pub fn init(cx: &mut AppContext) { + Picker::::init(cx); +} + +pub type ChannelModal = Picker; + +pub fn build_channel_modal( + user_store: ModelHandle, + channel_store: ModelHandle, + channel: ChannelId, + cx: &mut ViewContext, +) -> ChannelModal { + Picker::new( + ChannelModalDelegate { + potential_contacts: Arc::from([]), + selected_index: 0, + user_store, + channel_store, + channel_id: channel, + member_statuses: Default::default(), + }, + cx, + ) + .with_theme(|theme| theme.picker.clone()) +} + +pub struct ChannelModalDelegate { + potential_contacts: Arc<[Arc]>, + user_store: ModelHandle, + channel_store: ModelHandle, + channel_id: ChannelId, + selected_index: usize, + member_statuses: HashMap, +} + +impl PickerDelegate for ChannelModalDelegate { + fn placeholder_text(&self) -> Arc { + "Search collaborator by username...".into() + } + + 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>) { + self.selected_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> 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_mut().potential_contacts = potential_contacts.into(); + cx.notify(); + })?; + anyhow::Ok(()) + } + .log_err() + .await; + }) + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + 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>) { + cx.emit(PickerEvent::Dismiss); + } + + fn render_header( + &self, + cx: &mut ViewContext>, + ) -> Option>> { + let theme = &theme::current(cx).collab_panel.channel_modal; + + self.channel_store + .read(cx) + .channel_for_id(self.channel_id) + .map(|channel| { + Label::new( + format!("Add members for #{}", channel.name), + theme.picker.item.default_style().label.clone(), + ) + .into_any() + }) + } + + fn render_match( + &self, + ix: usize, + mouse_state: &mut MouseState, + selected: bool, + cx: &gpui::AppContext, + ) -> AnyElement> { + let theme = &theme::current(cx).collab_panel.channel_modal; + let user = &self.potential_contacts[ix]; + let request_status = self.member_statuses.get(&user.id); + + let icon_path = match request_status { + Some(ChannelMemberStatus::Member) => Some("icons/check_8.svg"), + Some(ChannelMemberStatus::Invited) => Some("icons/x_mark_8.svg"), + None => None, + }; + let button_style = if self.user_store.read(cx).is_contact_request_pending(user) { + &theme.disabled_contact_button + } else { + &theme.contact_button + }; + let style = theme.picker.item.in_state(selected).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(theme.row_height) + .into_any() + } +} diff --git a/crates/collab_ui/src/panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs similarity index 100% rename from crates/collab_ui/src/panel/contact_finder.rs rename to crates/collab_ui/src/collab_panel/contact_finder.rs diff --git a/crates/collab_ui/src/panel/panel_settings.rs b/crates/collab_ui/src/collab_panel/panel_settings.rs similarity index 100% rename from crates/collab_ui/src/panel/panel_settings.rs rename to crates/collab_ui/src/collab_panel/panel_settings.rs diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index c42ed34de6d5ad24929b4f3b493d9ec181dae96e..1e48026f466225439585b168cd9d9b79410de796 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,9 +1,9 @@ +pub mod collab_panel; mod collab_titlebar_item; mod contact_notification; mod face_pile; mod incoming_call_notification; mod notifications; -pub mod panel; mod project_shared_notification; mod sharing_status_indicator; @@ -22,7 +22,7 @@ actions!( pub fn init(app_state: &Arc, cx: &mut AppContext) { vcs_menu::init(cx); collab_titlebar_item::init(cx); - panel::init(app_state.client.clone(), cx); + collab_panel::init(app_state.client.clone(), cx); incoming_call_notification::init(&app_state, cx); project_shared_notification::init(&app_state, cx); sharing_status_indicator::init(cx); diff --git a/crates/collab_ui/src/panel/channel_modal.rs b/crates/collab_ui/src/panel/channel_modal.rs deleted file mode 100644 index 96424114c798c3e42bc8f5b7d41e211a6b668787..0000000000000000000000000000000000000000 --- a/crates/collab_ui/src/panel/channel_modal.rs +++ /dev/null @@ -1,119 +0,0 @@ -use client::{ChannelId, ChannelStore}; -use editor::Editor; -use gpui::{ - elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, View, ViewContext, ViewHandle, -}; -use menu::Cancel; -use workspace::{item::ItemHandle, Modal}; - -pub fn init(cx: &mut AppContext) { - cx.add_action(ChannelModal::cancel) -} - -pub struct ChannelModal { - has_focus: bool, - filter_editor: ViewHandle, - selection: usize, - list_state: ListState, - channel_store: ModelHandle, - channel_id: ChannelId, -} - -pub enum Event { - Dismiss, -} - -impl Entity for ChannelModal { - type Event = Event; -} - -impl ChannelModal { - pub fn new( - channel_id: ChannelId, - channel_store: ModelHandle, - cx: &mut ViewContext, - ) -> Self { - let input_editor = cx.add_view(|cx| { - let mut editor = Editor::single_line(None, cx); - editor.set_placeholder_text("Add a member", cx); - editor - }); - - let list_state = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { - Empty::new().into_any() - }); - - ChannelModal { - has_focus: false, - filter_editor: input_editor, - selection: 0, - list_state, - channel_id, - channel_store, - } - } - - pub fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - self.dismiss(cx); - } - - fn dismiss(&mut self, cx: &mut ViewContext) { - cx.emit(Event::Dismiss) - } -} - -impl View for ChannelModal { - fn ui_name() -> &'static str { - "Channel Modal" - } - - fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { - let theme = theme::current(cx).clone(); - let style = &theme.collab_panel.modal; - let modal_container = theme::current(cx).picker.container.clone(); - - enum ChannelModal {} - MouseEventHandler::::new(0, cx, |_, cx| { - Flex::column() - .with_child(ChildView::new(self.filter_editor.as_any(), cx)) - .with_child( - List::new(self.list_state.clone()) - .constrained() - .with_width(style.width) - .flex(1., true) - .into_any(), - ) - .contained() - .with_style(modal_container) - .constrained() - .with_max_width(540.) - .with_max_height(420.) - }) - .on_click(gpui::platform::MouseButton::Left, |_, _, _| {}) // Capture click and down events - .on_down_out(gpui::platform::MouseButton::Left, |_, v, cx| v.dismiss(cx)) - .into_any_named("channel modal") - } - - fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { - self.has_focus = true; - if cx.is_self_focused() { - cx.focus(&self.filter_editor); - } - } - - fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { - 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 { - Event::Dismiss => true, - } - } -} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index c4fb5aa6532e6d1e1394320edac0986b9d9fada7..1fdeef98f077ca6b700347d5b1cf55e1983d0440 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -138,6 +138,8 @@ message Envelope { UpdateChannels update_channels = 124; JoinChannel join_channel = 126; RemoveChannel remove_channel = 127; + GetChannelMembers get_channel_members = 128; + GetChannelMembersResponse get_channel_members_response = 129; } } @@ -886,6 +888,14 @@ message RemoveChannel { uint64 channel_id = 1; } +message GetChannelMembers { + uint64 channel_id = 1; +} + +message GetChannelMembersResponse { + repeated uint64 members = 1; + repeated uint64 invited_members = 2; +} message CreateChannel { string name = 1; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 07d54ce4db38381f014fe1ee77328010adabe1f8..c23bbb23e4f94674079f1f93de170ddcdbb69f11 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -244,7 +244,9 @@ messages!( (UpdateWorktreeSettings, Foreground), (UpdateDiffBase, Foreground), (GetPrivateUserInfo, Foreground), - (GetPrivateUserInfoResponse, Foreground) + (GetPrivateUserInfoResponse, Foreground), + (GetChannelMembers, Foreground), + (GetChannelMembersResponse, Foreground) ); request_messages!( @@ -296,6 +298,7 @@ request_messages!( (RemoveContact, Ack), (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), + (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), (RenameProjectEntry, ProjectEntryResponse), diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8f0ceeab883cfd55ec7cbbee7861ed7c181059a6..c557fbcf52bb74da0a1c5997ead20facc8903e8b 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,7 +220,7 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, - pub modal: ChannelModal, + pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, pub leave_call_button: IconButton, @@ -247,7 +247,12 @@ pub struct CollabPanel { #[derive(Deserialize, Default, JsonSchema)] pub struct ChannelModal { - pub width: f32, + pub picker: Picker, + pub row_height: f32, + pub contact_avatar: ImageStyle, + pub contact_username: ContainerStyle, + pub contact_button: IconButton, + pub disabled_contact_button: IconButton, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a779f39f5785987c0d118167a4fbe8477557ffd2..500a82d1ce32bb2fe4f1b1b06ad597a38d266b1d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -209,9 +209,9 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { ); cx.add_action( |workspace: &mut Workspace, - _: &collab_ui::panel::ToggleFocus, + _: &collab_ui::collab_panel::ToggleFocus, cx: &mut ViewContext| { - workspace.toggle_panel_focus::(cx); + workspace.toggle_panel_focus::(cx); }, ); cx.add_action( @@ -333,7 +333,7 @@ pub fn initialize_workspace( let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); let channels_panel = - collab_ui::panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); + collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!( project_panel, terminal_panel, diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 95ae337cbc64c2d738c802d936e3705c0b3f28e6..3eff0e4b9ab0b0318bd1e25f71726d9f3a3ad8cd 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -1,9 +1,74 @@ import { useTheme } from "../theme" +import { background, border, foreground, text } from "./components" +import picker from "./picker" export default function contacts_panel(): any { const theme = useTheme() + const side_margin = 6 + const contact_button = { + background: background(theme.middle, "variant"), + color: foreground(theme.middle, "variant"), + icon_width: 8, + button_width: 16, + corner_radius: 8, + } + + const picker_style = picker() + const picker_input = { + background: background(theme.middle, "on"), + corner_radius: 6, + text: text(theme.middle, "mono"), + placeholder_text: text(theme.middle, "mono", "on", "disabled", { + size: "xs", + }), + selection: theme.players[0], + border: border(theme.middle), + padding: { + bottom: 4, + left: 8, + right: 8, + top: 4, + }, + margin: { + left: side_margin, + right: side_margin, + }, + } + return { - width: 100, + picker: { + empty_container: {}, + item: { + ...picker_style.item, + margin: { left: side_margin, right: side_margin }, + }, + no_matches: picker_style.no_matches, + input_editor: picker_input, + empty_input_editor: picker_input, + header: picker_style.header, + footer: picker_style.footer, + }, + row_height: 28, + contact_avatar: { + corner_radius: 10, + width: 18, + }, + contact_username: { + padding: { + left: 8, + }, + }, + contact_button: { + ...contact_button, + hover: { + background: background(theme.middle, "variant", "hovered"), + }, + }, + disabled_contact_button: { + ...contact_button, + background: background(theme.middle, "disabled"), + color: foreground(theme.middle, "disabled"), + }, } } diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 37145d0c46b63e8ddbe14dd18964abbfd6919055..ea550dea6b45f90c82a38ee1bf9202a15ef03369 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -52,7 +52,7 @@ export default function contacts_panel(): any { } return { - modal: channel_modal(), + channel_modal: channel_modal(), background: background(layer), padding: { top: 12, From 9a1dd0c6bc3680cbf0e81f0b1864d7fbc068efef Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 3 Aug 2023 12:10:53 -0700 Subject: [PATCH 034/105] Fetch channel members before constructing channel mgmt modal --- crates/client/src/channel_store.rs | 2 +- crates/collab_ui/src/collab_panel.rs | 24 ++++++++++++------- .../src/collab_panel/channel_modal.rs | 3 ++- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index fcd0083c3b76616afc9c80334ea32ed131ff24a7..a1ee7ad6bc4c7de1f62f4552778f929dba05f033 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -123,7 +123,7 @@ impl ChannelStore { pub fn get_channel_members( &self, channel_id: ChannelId, - ) -> impl Future>> { + ) -> impl 'static + Future>> { let client = self.client.clone(); async move { let response = client diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index daad527979c5e32846f4a63b562e52a5011a4d86..34cb4f3e910c787faba9aab51afc577d6466fbe6 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1678,20 +1678,28 @@ impl CollabPanel { } fn add_member(&mut self, action: &AddMember, cx: &mut ViewContext) { - if let Some(workspace) = self.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { + let channel_id = action.channel_id; + let workspace = self.workspace.clone(); + let user_store = self.user_store.clone(); + let channel_store = self.channel_store.clone(); + let members = self.channel_store.read(cx).get_channel_members(channel_id); + cx.spawn(|_, mut cx| async move { + let members = members.await?; + workspace.update(&mut cx, |workspace, cx| { workspace.toggle_modal(cx, |_, cx| { cx.add_view(|cx| { build_channel_modal( - self.user_store.clone(), - self.channel_store.clone(), - action.channel_id, + user_store.clone(), + channel_store.clone(), + channel_id, + members, cx, ) }) - }) - }); - } + }); + }) + }) + .detach(); } fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 0cf24dbaf5025a4a2658480ada2a066ea37275cd..164759587d033241b574c4749dea60ec6ef9c864 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -17,6 +17,7 @@ pub fn build_channel_modal( user_store: ModelHandle, channel_store: ModelHandle, channel: ChannelId, + members: HashMap, cx: &mut ViewContext, ) -> ChannelModal { Picker::new( @@ -26,7 +27,7 @@ pub fn build_channel_modal( user_store, channel_store, channel_id: channel, - member_statuses: Default::default(), + member_statuses: members, }, cx, ) From 129f2890c5989357c58eaeb71fc605f396bb050d Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 3 Aug 2023 13:26:28 -0700 Subject: [PATCH 035/105] simplify server implementation --- crates/collab/src/rpc.rs | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 15237049c39f0c6a2fe9e4818196bba5ad5803ef..7ee2a2ba8331aaf2aaadf692da10b94df999213a 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2277,13 +2277,6 @@ async fn join_channel( let joined_room = { let db = session.db().await; - if db - .is_current_room_different_channel(session.user_id, channel_id) - .await? - { - leave_room_for_session_with_guard(&session, &db).await?; - } - let room_id = db.room_id_for_channel(channel_id).await?; let joined_room = db @@ -2539,14 +2532,7 @@ fn channel_updated( async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> { let db = session.db().await; - update_user_contacts_with_guard(user_id, session, &db).await -} -async fn update_user_contacts_with_guard( - user_id: UserId, - session: &Session, - db: &DbHandle, -) -> Result<()> { let contacts = db.get_contacts(user_id).await?; let busy = db.is_user_busy(user_id).await?; @@ -2580,11 +2566,6 @@ async fn update_user_contacts_with_guard( } async fn leave_room_for_session(session: &Session) -> Result<()> { - let db = session.db().await; - leave_room_for_session_with_guard(session, &db).await -} - -async fn leave_room_for_session_with_guard(session: &Session, db: &DbHandle) -> Result<()> { let mut contacts_to_update = HashSet::default(); let room_id; @@ -2595,7 +2576,7 @@ async fn leave_room_for_session_with_guard(session: &Session, db: &DbHandle) -> let channel_members; let channel_id; - if let Some(mut left_room) = db.leave_room(session.connection_id).await? { + if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? { contacts_to_update.insert(session.user_id); for project in left_room.left_projects.values() { @@ -2645,7 +2626,7 @@ async fn leave_room_for_session_with_guard(session: &Session, db: &DbHandle) -> } for contact_user_id in contacts_to_update { - update_user_contacts_with_guard(contact_user_id, &session, db).await?; + update_user_contacts(contact_user_id, &session).await?; } if let Some(live_kit) = session.live_kit_client.as_ref() { From a7e883d956852ef761edcb64096490c95ba0f4a3 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 3 Aug 2023 14:49:01 -0700 Subject: [PATCH 036/105] Implement basic channel member management UI Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 80 +++++--- crates/collab/src/db.rs | 80 ++++++-- crates/collab/src/db/tests.rs | 45 ++++- crates/collab/src/rpc.rs | 13 ++ crates/collab/src/tests.rs | 9 +- crates/collab/src/tests/channel_tests.rs | 45 ++++- crates/collab_ui/src/collab_panel.rs | 10 +- .../src/collab_panel/channel_modal.rs | 172 +++++++++++++----- crates/rpc/proto/zed.proto | 14 +- 9 files changed, 368 insertions(+), 100 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index a1ee7ad6bc4c7de1f62f4552778f929dba05f033..8568317355a67bbf767facbb37eccbdfc9796abd 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -1,6 +1,8 @@ use crate::{Client, Subscription, User, UserStore}; +use anyhow::anyhow; use anyhow::Result; use collections::HashMap; +use collections::HashSet; use futures::Future; use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, TypedEnvelope}; @@ -13,6 +15,7 @@ pub struct ChannelStore { channels: Vec>, channel_invitations: Vec>, channel_participants: HashMap>>, + outgoing_invites: HashSet<(ChannelId, UserId)>, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, @@ -33,6 +36,7 @@ impl Entity for ChannelStore { pub enum ChannelMemberStatus { Invited, Member, + NotMember, } impl ChannelStore { @@ -48,6 +52,7 @@ impl ChannelStore { channels: vec![], channel_invitations: vec![], channel_participants: Default::default(), + outgoing_invites: Default::default(), client, user_store, _rpc_subscription: rpc_subscription, @@ -88,13 +93,19 @@ impl ChannelStore { } pub fn invite_member( - &self, + &mut self, channel_id: ChannelId, user_id: UserId, admin: bool, - ) -> impl Future> { + cx: &mut ModelContext, + ) -> Task> { + if !self.outgoing_invites.insert((channel_id, user_id)) { + return Task::ready(Err(anyhow!("invite request already in progress"))); + } + + cx.notify(); let client = self.client.clone(); - async move { + cx.spawn(|this, mut cx| async move { client .request(proto::InviteChannelMember { channel_id, @@ -102,8 +113,12 @@ impl ChannelStore { admin, }) .await?; + this.update(&mut cx, |this, cx| { + this.outgoing_invites.remove(&(channel_id, user_id)); + cx.notify(); + }); Ok(()) - } + }) } pub fn respond_to_channel_invite( @@ -120,24 +135,34 @@ impl ChannelStore { } } - pub fn get_channel_members( + pub fn get_channel_member_details( &self, channel_id: ChannelId, - ) -> impl 'static + Future>> { + cx: &mut ModelContext, + ) -> Task, proto::channel_member::Kind)>>> { let client = self.client.clone(); - async move { + let user_store = self.user_store.downgrade(); + cx.spawn(|_, mut cx| async move { let response = client .request(proto::GetChannelMembers { channel_id }) .await?; - let mut result = HashMap::default(); - for member_id in response.members { - result.insert(member_id, ChannelMemberStatus::Member); - } - for invitee_id in response.invited_members { - result.insert(invitee_id, ChannelMemberStatus::Invited); - } - Ok(result) - } + + let user_ids = response.members.iter().map(|m| m.user_id).collect(); + let user_store = user_store + .upgrade(&cx) + .ok_or_else(|| anyhow!("user store dropped"))?; + let users = user_store + .update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx)) + .await?; + + Ok(users + .into_iter() + .zip(response.members) + .filter_map(|(user, member)| { + Some((user, proto::channel_member::Kind::from_i32(member.kind)?)) + }) + .collect()) + }) } pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future> { @@ -148,25 +173,22 @@ impl ChannelStore { } } - pub fn is_channel_invite_pending(&self, _: &Arc) -> bool { + pub fn has_pending_channel_invite_response(&self, _: &Arc) -> bool { false } - pub fn remove_member( - &self, - channel_id: ChannelId, - user_id: u64, - cx: &mut ModelContext, - ) -> Task> { - todo!() + pub fn has_pending_channel_invite(&self, channel_id: ChannelId, user_id: UserId) -> bool { + self.outgoing_invites.contains(&(channel_id, user_id)) } - pub fn channel_members( + pub fn remove_member( &self, - channel_id: ChannelId, - cx: &mut ModelContext, - ) -> Task>>> { - todo!() + _channel_id: ChannelId, + _user_id: u64, + _cx: &mut ModelContext, + ) -> Task> { + dbg!("TODO"); + Task::Ready(Some(Ok(()))) } async fn handle_update_channels( diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 36b226b97b340df4b7eca7d1e570f70ec2b98d89..d942b8cab9a3e7427f649792d7e0bca76683af59 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -213,20 +213,21 @@ impl Database { ); let (channel_id, room) = self.get_channel_room(room_id, &tx).await?; - let channel_members = if let Some(channel_id) = channel_id { - self.get_channel_members_internal(channel_id, &tx).await? + let channel_members; + if let Some(channel_id) = channel_id { + channel_members = self.get_channel_members_internal(channel_id, &tx).await?; } else { - Vec::new() - }; + channel_members = Vec::new(); - // Delete the room if it becomes empty. - if room.participants.is_empty() { - project::Entity::delete_many() - .filter(project::Column::RoomId.eq(room_id)) - .exec(&*tx) - .await?; - room::Entity::delete_by_id(room_id).exec(&*tx).await?; - } + // Delete the room if it becomes empty. + if room.participants.is_empty() { + project::Entity::delete_many() + .filter(project::Column::RoomId.eq(room_id)) + .exec(&*tx) + .await?; + room::Entity::delete_by_id(room_id).exec(&*tx).await?; + } + }; Ok(RefreshedRoom { room, @@ -3475,10 +3476,61 @@ impl Database { } pub async fn get_channel_members(&self, id: ChannelId) -> Result> { + self.transaction(|tx| async move { self.get_channel_members_internal(id, &*tx).await }) + .await + } + + // TODO: Add a chekc whether this user is allowed to read this channel + pub async fn get_channel_member_details( + &self, + id: ChannelId, + ) -> Result> { self.transaction(|tx| async move { + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryMemberDetails { + UserId, + IsDirectMember, + Accepted, + } + let tx = tx; - let user_ids = self.get_channel_members_internal(id, &*tx).await?; - Ok(user_ids) + let ancestor_ids = self.get_channel_ancestors(id, &*tx).await?; + let mut stream = channel_member::Entity::find() + .distinct() + .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) + .select_only() + .column(channel_member::Column::UserId) + .column_as( + channel_member::Column::ChannelId.eq(id), + QueryMemberDetails::IsDirectMember, + ) + .column(channel_member::Column::Accepted) + .order_by_asc(channel_member::Column::UserId) + .into_values::<_, QueryMemberDetails>() + .stream(&*tx) + .await?; + + let mut rows = Vec::::new(); + while let Some(row) = stream.next().await { + let (user_id, is_direct_member, is_invite_accepted): (UserId, bool, bool) = row?; + let kind = match (is_direct_member, is_invite_accepted) { + (true, true) => proto::channel_member::Kind::Member, + (true, false) => proto::channel_member::Kind::Invitee, + (false, true) => proto::channel_member::Kind::AncestorMember, + (false, false) => continue, + }; + let user_id = user_id.to_proto(); + let kind = kind.into(); + if let Some(last_row) = rows.last_mut() { + if last_row.user_id == user_id { + last_row.kind = last_row.kind.min(kind); + continue; + } + } + rows.push(proto::ChannelMember { user_id, kind }); + } + + Ok(rows) }) .await } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index a1d1a23dc96e36f6d662c7d20db06fbecf48ea22..e4161d3b5555ad2a50540622e836a4b628326f1f 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -1161,7 +1161,50 @@ test_both_dbs!( .map(|channel| channel.id) .collect::>(); - assert_eq!(user_3_invites, &[channel_1_1]) + assert_eq!(user_3_invites, &[channel_1_1]); + + let members = db.get_channel_member_details(channel_1_1).await.unwrap(); + assert_eq!( + members, + &[ + proto::ChannelMember { + user_id: user_1.to_proto(), + kind: proto::channel_member::Kind::Member.into(), + }, + proto::ChannelMember { + user_id: user_2.to_proto(), + kind: proto::channel_member::Kind::Invitee.into(), + }, + proto::ChannelMember { + user_id: user_3.to_proto(), + kind: proto::channel_member::Kind::Invitee.into(), + }, + ] + ); + + db.respond_to_channel_invite(channel_1_1, user_2, true) + .await + .unwrap(); + + let channel_1_3 = db + .create_channel("channel_3", Some(channel_1_1), "1", user_1) + .await + .unwrap(); + + let members = db.get_channel_member_details(channel_1_3).await.unwrap(); + assert_eq!( + members, + &[ + proto::ChannelMember { + user_id: user_1.to_proto(), + kind: proto::channel_member::Kind::Member.into(), + }, + proto::ChannelMember { + user_id: user_2.to_proto(), + kind: proto::channel_member::Kind::AncestorMember.into(), + }, + ] + ); } ); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7ee2a2ba8331aaf2aaadf692da10b94df999213a..fdfccea98f95e0d277e3b876c984b588a6a28c25 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -246,6 +246,7 @@ impl Server { .add_request_handler(remove_channel) .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) + .add_request_handler(get_channel_members) .add_request_handler(respond_to_channel_invite) .add_request_handler(join_channel) .add_request_handler(follow) @@ -2236,6 +2237,18 @@ async fn remove_channel_member( Ok(()) } +async fn get_channel_members( + request: proto::GetChannelMembers, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let members = db.get_channel_member_details(channel_id).await?; + response.send(proto::GetChannelMembersResponse { members })?; + Ok(()) +} + async fn respond_to_channel_invite( request: proto::RespondToChannelInvite, response: Response, diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 26ca5a008efc4bb5b37bd6eb9d39634b8d5e4973..a8e2a129628c6466b873b71a068a3ad088b6a48f 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -291,8 +291,13 @@ impl TestServer { admin_client .app_state .channel_store - .update(admin_cx, |channel_store, _| { - channel_store.invite_member(channel_id, member_client.user_id().unwrap(), false) + .update(admin_cx, |channel_store, cx| { + channel_store.invite_member( + channel_id, + member_client.user_id().unwrap(), + false, + cx, + ) }) .await .unwrap(); diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 39997405572d11c4fb179aeada73de7cd30cd98d..b4f8477a2d27336099c39878d7770afa66c6c3bd 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,6 +1,7 @@ use call::ActiveCall; use client::{Channel, User}; use gpui::{executor::Deterministic, TestAppContext}; +use rpc::proto; use std::sync::Arc; use crate::tests::{room_participants, RoomParticipants}; @@ -46,8 +47,14 @@ async fn test_basic_channels( // Invite client B to channel A as client A. client_a .channel_store() - .update(cx_a, |channel_store, _| { - channel_store.invite_member(channel_a_id, client_b.user_id().unwrap(), false) + .update(cx_a, |store, cx| { + assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); + + let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx); + + // Make sure we're synchronously storing the pending invite + assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); + invite }) .await .unwrap(); @@ -66,6 +73,27 @@ async fn test_basic_channels( })] ) }); + let members = client_a + .channel_store() + .update(cx_a, |store, cx| { + assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); + store.get_channel_member_details(channel_a_id, cx) + }) + .await + .unwrap(); + assert_members_eq( + &members, + &[ + ( + client_a.user_id().unwrap(), + proto::channel_member::Kind::Member, + ), + ( + client_b.user_id().unwrap(), + proto::channel_member::Kind::Invitee, + ), + ], + ); // Client B now sees that they are a member channel A. client_b @@ -113,6 +141,19 @@ fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u ); } +fn assert_members_eq( + members: &[(Arc, proto::channel_member::Kind)], + expected_members: &[(u64, proto::channel_member::Kind)], +) { + assert_eq!( + members + .iter() + .map(|(user, status)| (user.id, *status)) + .collect::>(), + expected_members + ); +} + #[gpui::test] async fn test_channel_room( deterministic: Arc, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 34cb4f3e910c787faba9aab51afc577d6466fbe6..771927c8ac4c45b9b22b06ae17e27d45854e15e3 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1333,7 +1333,9 @@ impl CollabPanel { enum Accept {} let channel_id = channel.id; - let is_invite_pending = channel_store.read(cx).is_channel_invite_pending(&channel); + let is_invite_pending = channel_store + .read(cx) + .has_pending_channel_invite_response(&channel); let button_spacing = theme.contact_button_spacing; Flex::row() @@ -1682,7 +1684,10 @@ impl CollabPanel { let workspace = self.workspace.clone(); let user_store = self.user_store.clone(); let channel_store = self.channel_store.clone(); - let members = self.channel_store.read(cx).get_channel_members(channel_id); + let members = self.channel_store.update(cx, |channel_store, cx| { + channel_store.get_channel_member_details(channel_id, cx) + }); + cx.spawn(|_, mut cx| async move { let members = members.await?; workspace.update(&mut cx, |workspace, cx| { @@ -1692,6 +1697,7 @@ impl CollabPanel { user_store.clone(), channel_store.clone(), channel_id, + channel_modal::Mode::InviteMembers, members, cx, ) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 164759587d033241b574c4749dea60ec6ef9c864..e6a3ba928878cfdcf42b0a3e35d4d9f29d82167f 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -1,7 +1,5 @@ -use client::{ - ChannelId, ChannelMemberStatus, ChannelStore, ContactRequestStatus, User, UserId, UserStore, -}; -use collections::HashMap; +use client::{proto, ChannelId, ChannelStore, User, UserId, UserStore}; +use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext}; use picker::{Picker, PickerDelegate, PickerEvent}; use std::sync::Arc; @@ -17,30 +15,48 @@ pub fn build_channel_modal( user_store: ModelHandle, channel_store: ModelHandle, channel: ChannelId, - members: HashMap, + mode: Mode, + members: Vec<(Arc, proto::channel_member::Kind)>, cx: &mut ViewContext, ) -> ChannelModal { Picker::new( ChannelModalDelegate { - potential_contacts: Arc::from([]), + matches: Vec::new(), selected_index: 0, user_store, channel_store, channel_id: channel, - member_statuses: members, + match_candidates: members + .iter() + .enumerate() + .map(|(id, member)| StringMatchCandidate { + id, + string: member.0.github_login.clone(), + char_bag: member.0.github_login.chars().collect(), + }) + .collect(), + members, + mode, }, cx, ) .with_theme(|theme| theme.picker.clone()) } +pub enum Mode { + ManageMembers, + InviteMembers, +} + pub struct ChannelModalDelegate { - potential_contacts: Arc<[Arc]>, + matches: Vec<(Arc, Option)>, user_store: ModelHandle, channel_store: ModelHandle, channel_id: ChannelId, selected_index: usize, - member_statuses: HashMap, + mode: Mode, + match_candidates: Arc<[StringMatchCandidate]>, + members: Vec<(Arc, proto::channel_member::Kind)>, } impl PickerDelegate for ChannelModalDelegate { @@ -49,7 +65,7 @@ impl PickerDelegate for ChannelModalDelegate { } fn match_count(&self) -> usize { - self.potential_contacts.len() + self.matches.len() } fn selected_index(&self) -> usize { @@ -61,39 +77,80 @@ impl PickerDelegate for ChannelModalDelegate { } fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> 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_mut().potential_contacts = potential_contacts.into(); - cx.notify(); - })?; - anyhow::Ok(()) + match self.mode { + Mode::ManageMembers => { + let match_candidates = self.match_candidates.clone(); + cx.spawn(|picker, mut cx| async move { + async move { + let matches = match_strings( + &match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + cx.background().clone(), + ) + .await; + picker.update(&mut cx, |picker, cx| { + let delegate = picker.delegate_mut(); + delegate.matches.clear(); + delegate.matches.extend(matches.into_iter().map(|m| { + let member = &delegate.members[m.candidate_id]; + (member.0.clone(), Some(member.1)) + })); + cx.notify(); + })?; + anyhow::Ok(()) + } + .log_err() + .await; + }) + } + 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| { + let delegate = picker.delegate_mut(); + delegate.matches.clear(); + delegate + .matches + .extend(users.into_iter().map(|user| (user, None))); + cx.notify(); + })?; + anyhow::Ok(()) + } + .log_err() + .await; + }) } - .log_err() - .await; - }) + } } fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { - 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(); + if let Some((user, _)) = self.matches.get(self.selected_index) { + match self.mode { + Mode::ManageMembers => { + // } - _ => {} + Mode::InviteMembers => match self.member_status(user.id, cx) { + Some(proto::channel_member::Kind::Member) => {} + Some(proto::channel_member::Kind::Invitee) => self + .channel_store + .update(cx, |store, cx| { + store.remove_member(self.channel_id, user.id, cx) + }) + .detach(), + Some(proto::channel_member::Kind::AncestorMember) | None => self + .channel_store + .update(cx, |store, cx| { + store.invite_member(self.channel_id, user.id, false, cx) + }) + .detach(), + }, } } } @@ -108,12 +165,16 @@ impl PickerDelegate for ChannelModalDelegate { ) -> Option>> { let theme = &theme::current(cx).collab_panel.channel_modal; + let operation = match self.mode { + Mode::ManageMembers => "Manage", + Mode::InviteMembers => "Add", + }; self.channel_store .read(cx) .channel_for_id(self.channel_id) .map(|channel| { Label::new( - format!("Add members for #{}", channel.name), + format!("{} members for #{}", operation, channel.name), theme.picker.item.default_style().label.clone(), ) .into_any() @@ -128,19 +189,17 @@ impl PickerDelegate for ChannelModalDelegate { cx: &gpui::AppContext, ) -> AnyElement> { let theme = &theme::current(cx).collab_panel.channel_modal; - let user = &self.potential_contacts[ix]; - let request_status = self.member_statuses.get(&user.id); + let (user, _) = &self.matches[ix]; + let request_status = self.member_status(user.id, cx); let icon_path = match request_status { - Some(ChannelMemberStatus::Member) => Some("icons/check_8.svg"), - Some(ChannelMemberStatus::Invited) => Some("icons/x_mark_8.svg"), + Some(proto::channel_member::Kind::AncestorMember) => Some("icons/check_8.svg"), + Some(proto::channel_member::Kind::Member) => Some("icons/check_8.svg"), + Some(proto::channel_member::Kind::Invitee) => Some("icons/x_mark_8.svg"), None => None, }; - let button_style = if self.user_store.read(cx).is_contact_request_pending(user) { - &theme.disabled_contact_button - } else { - &theme.contact_button - }; + let button_style = &theme.contact_button; + let style = theme.picker.item.in_state(selected).style_for(mouse_state); Flex::row() .with_children(user.avatar.clone().map(|avatar| { @@ -177,3 +236,20 @@ impl PickerDelegate for ChannelModalDelegate { .into_any() } } + +impl ChannelModalDelegate { + fn member_status( + &self, + user_id: UserId, + cx: &AppContext, + ) -> Option { + self.members + .iter() + .find_map(|(user, status)| (user.id == user_id).then_some(*status)) + .or(self + .channel_store + .read(cx) + .has_pending_channel_invite(self.channel_id, user_id) + .then_some(proto::channel_member::Kind::Invitee)) + } +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 1fdeef98f077ca6b700347d5b1cf55e1983d0440..602b34529e7a1fc73c8c0264f9cac975e40d7be6 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -893,8 +893,18 @@ message GetChannelMembers { } message GetChannelMembersResponse { - repeated uint64 members = 1; - repeated uint64 invited_members = 2; + repeated ChannelMember members = 1; +} + +message ChannelMember { + uint64 user_id = 1; + Kind kind = 2; + + enum Kind { + Member = 0; + Invitee = 1; + AncestorMember = 2; + } } message CreateChannel { From 4a6c73c6fda413f66ba55eb167fc2649bd21d6ee Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 3 Aug 2023 16:15:29 -0700 Subject: [PATCH 037/105] Lay-out channel modal with picker beneath channel name and mode buttons Co-authored-by: Mikayla --- .../src/collab_panel/channel_modal.rs | 199 +++++++++++++----- crates/picker/src/picker.rs | 1 + crates/theme/src/theme.rs | 4 + styles/src/style_tree/channel_modal.ts | 57 ++++- 4 files changed, 213 insertions(+), 48 deletions(-) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index e6a3ba928878cfdcf42b0a3e35d4d9f29d82167f..9af6099f655dfc9389f516b129736a8545fe5d05 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -1,48 +1,175 @@ use client::{proto, ChannelId, ChannelStore, User, UserId, UserStore}; use fuzzy::{match_strings, StringMatchCandidate}; -use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext}; +use gpui::{ + elements::*, + platform::{CursorStyle, MouseButton}, + AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle, +}; use picker::{Picker, PickerDelegate, PickerEvent}; use std::sync::Arc; use util::TryFutureExt; +use workspace::Modal; pub fn init(cx: &mut AppContext) { Picker::::init(cx); } -pub type ChannelModal = Picker; +pub struct ChannelModal { + picker: ViewHandle>, + channel_store: ModelHandle, + channel_id: ChannelId, + has_focus: bool, +} + +impl Entity for ChannelModal { + type Event = PickerEvent; +} + +impl View for ChannelModal { + fn ui_name() -> &'static str { + "ChannelModal" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = &theme::current(cx).collab_panel.channel_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() + }; + + enum InviteMembers {} + enum ManageMembers {} + + fn render_mode_button( + mode: Mode, + text: &'static str, + current_mode: Mode, + theme: &theme::ChannelModal, + cx: &mut ViewContext, + ) -> AnyElement { + let active = mode == current_mode; + MouseEventHandler::::new(0, cx, move |state, _| { + let contained_text = theme.mode_button.style_for(active, state); + Label::new(text, contained_text.text.clone()) + .contained() + .with_style(contained_text.container.clone()) + }) + .on_click(MouseButton::Left, move |_, this, cx| { + if !active { + this.picker.update(cx, |picker, cx| { + picker.delegate_mut().mode = mode; + picker.update_matches(picker.query(cx), cx); + cx.notify(); + }) + } + }) + .with_cursor_style(if active { + CursorStyle::Arrow + } else { + CursorStyle::PointingHand + }) + .into_any() + } + + Flex::column() + .with_child(Label::new( + format!("#{}", channel.name), + theme.header.clone(), + )) + .with_child(Flex::row().with_children([ + render_mode_button::( + Mode::InviteMembers, + "Invite members", + mode, + theme, + cx, + ), + render_mode_button::( + Mode::ManageMembers, + "Manage members", + mode, + theme, + cx, + ), + ])) + .with_child(ChildView::new(&self.picker, cx)) + .constrained() + .with_height(theme.height) + .contained() + .with_style(theme.container) + .into_any() + } + + fn focus_in(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + self.has_focus = true; + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + 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, + } + } +} pub fn build_channel_modal( user_store: ModelHandle, channel_store: ModelHandle, - channel: ChannelId, + channel_id: ChannelId, mode: Mode, members: Vec<(Arc, proto::channel_member::Kind)>, cx: &mut ViewContext, ) -> ChannelModal { - Picker::new( - ChannelModalDelegate { - matches: Vec::new(), - selected_index: 0, - user_store, - channel_store, - channel_id: channel, - match_candidates: members - .iter() - .enumerate() - .map(|(id, member)| StringMatchCandidate { - id, - string: member.0.github_login.clone(), - char_bag: member.0.github_login.chars().collect(), - }) - .collect(), - members, - mode, - }, - cx, - ) - .with_theme(|theme| theme.picker.clone()) + let picker = cx.add_view(|cx| { + Picker::new( + ChannelModalDelegate { + matches: Vec::new(), + selected_index: 0, + user_store: user_store.clone(), + channel_store: channel_store.clone(), + channel_id, + match_candidates: members + .iter() + .enumerate() + .map(|(id, member)| StringMatchCandidate { + id, + string: member.0.github_login.clone(), + char_bag: member.0.github_login.chars().collect(), + }) + .collect(), + members, + mode, + }, + cx, + ) + .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone()) + }); + + cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); + let has_focus = picker.read(cx).has_focus(); + + ChannelModal { + picker, + channel_store, + channel_id, + has_focus, + } } +#[derive(Copy, Clone, PartialEq)] pub enum Mode { ManageMembers, InviteMembers, @@ -159,28 +286,6 @@ impl PickerDelegate for ChannelModalDelegate { cx.emit(PickerEvent::Dismiss); } - fn render_header( - &self, - cx: &mut ViewContext>, - ) -> Option>> { - let theme = &theme::current(cx).collab_panel.channel_modal; - - let operation = match self.mode { - Mode::ManageMembers => "Manage", - Mode::InviteMembers => "Add", - }; - self.channel_store - .read(cx) - .channel_for_id(self.channel_id) - .map(|channel| { - Label::new( - format!("{} members for #{}", operation, channel.name), - theme.picker.item.default_style().label.clone(), - ) - .into_any() - }) - } - fn render_match( &self, ix: usize, diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 6efa33e961b3e43911226ea04256eee64d56178b..ef8b75d1b3127ae23563af06366119f9f7aac4b3 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -13,6 +13,7 @@ use std::{cmp, sync::Arc}; use util::ResultExt; use workspace::Modal; +#[derive(Clone, Copy)] pub enum PickerEvent { Dismiss, } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index c557fbcf52bb74da0a1c5997ead20facc8903e8b..8d0159d7adba6bd4b7678845b668e49db58e08f1 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -247,6 +247,10 @@ pub struct CollabPanel { #[derive(Deserialize, Default, JsonSchema)] pub struct ChannelModal { + pub container: ContainerStyle, + pub height: f32, + pub header: TextStyle, + pub mode_button: Toggleable>, pub picker: Picker, pub row_height: f32, pub contact_avatar: ImageStyle, diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 3eff0e4b9ab0b0318bd1e25f71726d9f3a3ad8cd..951591676b5790811a39dbac790872694bd1de1c 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -1,8 +1,9 @@ import { useTheme } from "../theme" +import { interactive, toggleable } from "../element" import { background, border, foreground, text } from "./components" import picker from "./picker" -export default function contacts_panel(): any { +export default function channel_modal(): any { const theme = useTheme() const side_margin = 6 @@ -15,6 +16,9 @@ export default function contacts_panel(): any { } const picker_style = picker() + delete picker_style.shadow + delete picker_style.border + const picker_input = { background: background(theme.middle, "on"), corner_radius: 6, @@ -37,6 +41,57 @@ export default function contacts_panel(): any { } return { + container: { + background: background(theme.lowest), + border: border(theme.lowest), + shadow: theme.modal_shadow, + corner_radius: 12, + padding: { + bottom: 4, + left: 20, + right: 20, + top: 20, + }, + }, + height: 400, + header: text(theme.middle, "sans", "on", { size: "lg" }), + mode_button: toggleable({ + base: interactive({ + base: { + ...text(theme.middle, "sans", { size: "xs" }), + border: border(theme.middle, "active"), + corner_radius: 4, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + + margin: { left: 6, top: 6, bottom: 6 }, + }, + state: { + hovered: { + ...text(theme.middle, "sans", "default", { size: "xs" }), + background: background(theme.middle, "hovered"), + border: border(theme.middle, "active"), + }, + }, + }), + state: { + active: { + default: { + color: foreground(theme.middle, "accent"), + }, + hovered: { + color: foreground(theme.middle, "accent", "hovered"), + }, + clicked: { + color: foreground(theme.middle, "accent", "pressed"), + }, + }, + } + }), picker: { empty_container: {}, item: { From 95b1ab9574aeb6934c9b18ade8a2a84d3efc3932 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 3 Aug 2023 18:03:40 -0700 Subject: [PATCH 038/105] Implement channel member removal, permission check for member retrieval --- crates/client/src/channel_store.rs | 37 ++- crates/collab/src/db.rs | 244 +++++++++--------- crates/collab/src/db/tests.rs | 16 +- crates/collab/src/rpc.rs | 20 +- .../src/collab_panel/channel_modal.rs | 9 +- 5 files changed, 186 insertions(+), 140 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 8568317355a67bbf767facbb37eccbdfc9796abd..1d3bbd4435e70f159a980cac9525593057cf1020 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -121,6 +121,33 @@ impl ChannelStore { }) } + pub fn remove_member( + &mut self, + channel_id: ChannelId, + user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + if !self.outgoing_invites.insert((channel_id, user_id)) { + return Task::ready(Err(anyhow!("invite request already in progress"))); + } + + cx.notify(); + let client = self.client.clone(); + cx.spawn(|this, mut cx| async move { + client + .request(proto::RemoveChannelMember { + channel_id, + user_id, + }) + .await?; + this.update(&mut cx, |this, cx| { + this.outgoing_invites.remove(&(channel_id, user_id)); + cx.notify(); + }); + Ok(()) + }) + } + pub fn respond_to_channel_invite( &mut self, channel_id: ChannelId, @@ -181,16 +208,6 @@ impl ChannelStore { self.outgoing_invites.contains(&(channel_id, user_id)) } - pub fn remove_member( - &self, - _channel_id: ChannelId, - _user_id: u64, - _cx: &mut ModelContext, - ) -> Task> { - dbg!("TODO"); - Task::Ready(Some(Ok(()))) - } - async fn handle_update_channels( this: ModelHandle, message: TypedEnvelope, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index d942b8cab9a3e7427f649792d7e0bca76683af59..5a2ab24b1e22e2661e8c02af5f9a63145dc6fc71 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3165,30 +3165,17 @@ impl Database { creator_id: UserId, ) -> Result { self.transaction(move |tx| async move { - let tx = tx; - if let Some(parent) = parent { - let channels = self.get_channel_ancestors(parent, &*tx).await?; - channel_member::Entity::find() - .filter(channel_member::Column::ChannelId.is_in(channels.iter().copied())) - .filter( - channel_member::Column::UserId - .eq(creator_id) - .and(channel_member::Column::Accepted.eq(true)), - ) - .one(&*tx) - .await? - .ok_or_else(|| { - anyhow!("User does not have the permissions to create this channel") - })?; + self.check_user_is_channel_admin(parent, creator_id, &*tx) + .await?; } let channel = channel::ActiveModel { name: ActiveValue::Set(name.to_string()), ..Default::default() - }; - - let channel = channel.insert(&*tx).await?; + } + .insert(&*tx) + .await?; if let Some(parent) = parent { channel_parent::ActiveModel { @@ -3228,45 +3215,36 @@ impl Database { user_id: UserId, ) -> Result<(Vec, Vec)> { self.transaction(move |tx| async move { - let tx = tx; - - // Check if user is an admin - channel_member::Entity::find() - .filter( - channel_member::Column::ChannelId - .eq(channel_id) - .and(channel_member::Column::UserId.eq(user_id)) - .and(channel_member::Column::Admin.eq(true)), - ) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("user is not allowed to remove this channel"))?; - - let mut descendants = self.get_channel_descendants([channel_id], &*tx).await?; - - // Keep channels which have another active - let mut channels_to_keep = channel_parent::Entity::find() - .filter( - channel_parent::Column::ChildId - .is_in(descendants.keys().copied().filter(|&id| id != channel_id)) - .and( - channel_parent::Column::ParentId.is_not_in(descendants.keys().copied()), - ), - ) - .stream(&*tx) + self.check_user_is_channel_admin(channel_id, user_id, &*tx) .await?; - while let Some(row) = channels_to_keep.next().await { - let row = row?; - descendants.remove(&row.child_id); + // Don't remove descendant channels that have additional parents. + let mut channels_to_remove = self.get_channel_descendants([channel_id], &*tx).await?; + { + let mut channels_to_keep = channel_parent::Entity::find() + .filter( + channel_parent::Column::ChildId + .is_in( + channels_to_remove + .keys() + .copied() + .filter(|&id| id != channel_id), + ) + .and( + channel_parent::Column::ParentId + .is_not_in(channels_to_remove.keys().copied()), + ), + ) + .stream(&*tx) + .await?; + while let Some(row) = channels_to_keep.next().await { + let row = row?; + channels_to_remove.remove(&row.child_id); + } } - drop(channels_to_keep); - - let channels_to_remove = descendants.keys().copied().collect::>(); - let members_to_notify: Vec = channel_member::Entity::find() - .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.iter().copied())) + .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.keys().copied())) .select_only() .column(channel_member::Column::UserId) .distinct() @@ -3274,13 +3252,12 @@ impl Database { .all(&*tx) .await?; - // Channel members and parents should delete via cascade channel::Entity::delete_many() - .filter(channel::Column::Id.is_in(channels_to_remove.iter().copied())) + .filter(channel::Column::Id.is_in(channels_to_remove.keys().copied())) .exec(&*tx) .await?; - Ok((channels_to_remove, members_to_notify)) + Ok((channels_to_remove.into_keys().collect(), members_to_notify)) }) .await } @@ -3293,31 +3270,18 @@ impl Database { is_admin: bool, ) -> Result<()> { self.transaction(move |tx| async move { - let tx = tx; - - // Check if inviter is a member - channel_member::Entity::find() - .filter( - channel_member::Column::ChannelId - .eq(channel_id) - .and(channel_member::Column::UserId.eq(inviter_id)) - .and(channel_member::Column::Admin.eq(true)), - ) - .one(&*tx) - .await? - .ok_or_else(|| { - anyhow!("Inviter does not have permissions to invite the invitee") - })?; + self.check_user_is_channel_admin(channel_id, inviter_id, &*tx) + .await?; - let channel_membership = channel_member::ActiveModel { + channel_member::ActiveModel { channel_id: ActiveValue::Set(channel_id), user_id: ActiveValue::Set(invitee_id), accepted: ActiveValue::Set(false), admin: ActiveValue::Set(is_admin), ..Default::default() - }; - - channel_membership.insert(&*tx).await?; + } + .insert(&*tx) + .await?; Ok(()) }) @@ -3331,8 +3295,6 @@ impl Database { accept: bool, ) -> Result<()> { self.transaction(move |tx| async move { - let tx = tx; - let rows_affected = if accept { channel_member::Entity::update_many() .set(channel_member::ActiveModel { @@ -3368,10 +3330,36 @@ impl Database { .await } - pub async fn get_channel_invites(&self, user_id: UserId) -> Result> { + pub async fn remove_channel_member( + &self, + channel_id: ChannelId, + member_id: UserId, + remover_id: UserId, + ) -> Result<()> { self.transaction(|tx| async move { - let tx = tx; + self.check_user_is_channel_admin(channel_id, remover_id, &*tx) + .await?; + + let result = channel_member::Entity::delete_many() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(member_id)), + ) + .exec(&*tx) + .await?; + + if result.rows_affected == 0 { + Err(anyhow!("no such member"))?; + } + + Ok(()) + }) + .await + } + pub async fn get_channel_invites_for_user(&self, user_id: UserId) -> Result> { + self.transaction(|tx| async move { let channel_invites = channel_member::Entity::find() .filter( channel_member::Column::UserId @@ -3406,7 +3394,7 @@ impl Database { .await } - pub async fn get_channels( + pub async fn get_channels_for_user( &self, user_id: UserId, ) -> Result<(Vec, HashMap>)> { @@ -3430,47 +3418,48 @@ impl Database { .await?; let mut channels = Vec::with_capacity(parents_by_child_id.len()); - let mut rows = channel::Entity::find() - .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied())) - .stream(&*tx) - .await?; - - while let Some(row) = rows.next().await { - let row = row?; - channels.push(Channel { - id: row.id, - name: row.name, - parent_id: parents_by_child_id.get(&row.id).copied().flatten(), - }); + { + let mut rows = channel::Entity::find() + .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied())) + .stream(&*tx) + .await?; + while let Some(row) = rows.next().await { + let row = row?; + channels.push(Channel { + id: row.id, + name: row.name, + parent_id: parents_by_child_id.get(&row.id).copied().flatten(), + }); + } } - drop(rows); - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryUserIdsAndChannelIds { ChannelId, UserId, } - let mut participants = room_participant::Entity::find() - .inner_join(room::Entity) - .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id))) - .select_only() - .column(room::Column::ChannelId) - .column(room_participant::Column::UserId) - .into_values::<_, QueryUserIdsAndChannelIds>() - .stream(&*tx) - .await?; - - let mut participant_map: HashMap> = HashMap::default(); - while let Some(row) = participants.next().await { - let row: (ChannelId, UserId) = row?; - participant_map.entry(row.0).or_default().push(row.1) + let mut participants_by_channel: HashMap> = HashMap::default(); + { + let mut rows = room_participant::Entity::find() + .inner_join(room::Entity) + .filter(room::Column::ChannelId.is_in(channels.iter().map(|c| c.id))) + .select_only() + .column(room::Column::ChannelId) + .column(room_participant::Column::UserId) + .into_values::<_, QueryUserIdsAndChannelIds>() + .stream(&*tx) + .await?; + while let Some(row) = rows.next().await { + let row: (ChannelId, UserId) = row?; + participants_by_channel + .entry(row.0) + .or_default() + .push(row.1) + } } - drop(participants); - - Ok((channels, participant_map)) + Ok((channels, participants_by_channel)) }) .await } @@ -3480,12 +3469,15 @@ impl Database { .await } - // TODO: Add a chekc whether this user is allowed to read this channel pub async fn get_channel_member_details( &self, - id: ChannelId, + channel_id: ChannelId, + user_id: UserId, ) -> Result> { self.transaction(|tx| async move { + self.check_user_is_channel_admin(channel_id, user_id, &*tx) + .await?; + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryMemberDetails { UserId, @@ -3494,14 +3486,14 @@ impl Database { } let tx = tx; - let ancestor_ids = self.get_channel_ancestors(id, &*tx).await?; + let ancestor_ids = self.get_channel_ancestors(channel_id, &*tx).await?; let mut stream = channel_member::Entity::find() .distinct() .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) .select_only() .column(channel_member::Column::UserId) .column_as( - channel_member::Column::ChannelId.eq(id), + channel_member::Column::ChannelId.eq(channel_id), QueryMemberDetails::IsDirectMember, ) .column(channel_member::Column::Accepted) @@ -3552,9 +3544,29 @@ impl Database { Ok(user_ids) } + async fn check_user_is_channel_admin( + &self, + channel_id: ChannelId, + user_id: UserId, + tx: &DatabaseTransaction, + ) -> Result<()> { + let channel_ids = self.get_channel_ancestors(channel_id, tx).await?; + channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .is_in(channel_ids) + .and(channel_member::Column::UserId.eq(user_id)) + .and(channel_member::Column::Admin.eq(true)), + ) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("user is not allowed to remove this channel"))?; + Ok(()) + } + async fn get_channel_ancestors( &self, - id: ChannelId, + channel_id: ChannelId, tx: &DatabaseTransaction, ) -> Result> { let sql = format!( @@ -3570,7 +3582,7 @@ impl Database { SELECT DISTINCT channel_tree.parent_id FROM channel_tree "#, - id + channel_id ); #[derive(FromQueryResult, Debug, PartialEq)] diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index e4161d3b5555ad2a50540622e836a4b628326f1f..b4c22430e5bdd955989429f2d514da3dd6d0bff0 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -951,7 +951,7 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .await .unwrap(); - let (channels, _) = db.get_channels(a_id).await.unwrap(); + let (channels, _) = db.get_channels_for_user(a_id).await.unwrap(); assert_eq!( channels, @@ -1144,7 +1144,7 @@ test_both_dbs!( .unwrap(); let user_2_invites = db - .get_channel_invites(user_2) // -> [channel_1_1, channel_1_2] + .get_channel_invites_for_user(user_2) // -> [channel_1_1, channel_1_2] .await .unwrap() .into_iter() @@ -1154,7 +1154,7 @@ test_both_dbs!( assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]); let user_3_invites = db - .get_channel_invites(user_3) // -> [channel_1_1] + .get_channel_invites_for_user(user_3) // -> [channel_1_1] .await .unwrap() .into_iter() @@ -1163,7 +1163,10 @@ test_both_dbs!( assert_eq!(user_3_invites, &[channel_1_1]); - let members = db.get_channel_member_details(channel_1_1).await.unwrap(); + let members = db + .get_channel_member_details(channel_1_1, user_1) + .await + .unwrap(); assert_eq!( members, &[ @@ -1191,7 +1194,10 @@ test_both_dbs!( .await .unwrap(); - let members = db.get_channel_member_details(channel_1_3).await.unwrap(); + let members = db + .get_channel_member_details(channel_1_3, user_1) + .await + .unwrap(); assert_eq!( members, &[ diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index fdfccea98f95e0d277e3b876c984b588a6a28c25..17f13345444c216f803892bb113ec9944b2de5ee 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -530,8 +530,8 @@ impl Server { let (contacts, invite_code, (channels, channel_participants), channel_invites) = future::try_join4( this.app_state.db.get_contacts(user_id), this.app_state.db.get_invite_code_for_user(user_id), - this.app_state.db.get_channels(user_id), - this.app_state.db.get_channel_invites(user_id) + this.app_state.db.get_channels_for_user(user_id), + this.app_state.db.get_channel_invites_for_user(user_id) ).await?; { @@ -2230,10 +2230,16 @@ async fn invite_channel_member( } async fn remove_channel_member( - _request: proto::RemoveChannelMember, - _response: Response, - _session: Session, + request: proto::RemoveChannelMember, + response: Response, + session: Session, ) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let member_id = UserId::from_proto(request.user_id); + db.remove_channel_member(channel_id, member_id, session.user_id) + .await?; + response.send(proto::Ack {})?; Ok(()) } @@ -2244,7 +2250,9 @@ async fn get_channel_members( ) -> Result<()> { let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - let members = db.get_channel_member_details(channel_id).await?; + let members = db + .get_channel_member_details(channel_id, session.user_id) + .await?; response.send(proto::GetChannelMembersResponse { members })?; Ok(()) } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 9af6099f655dfc9389f516b129736a8545fe5d05..56285400221203c66d399be99ca98c2d8471872c 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -260,9 +260,12 @@ impl PickerDelegate for ChannelModalDelegate { fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { if let Some((user, _)) = self.matches.get(self.selected_index) { match self.mode { - Mode::ManageMembers => { - // - } + Mode::ManageMembers => self + .channel_store + .update(cx, |store, cx| { + store.remove_member(self.channel_id, user.id, cx) + }) + .detach(), Mode::InviteMembers => match self.member_status(user.id, cx) { Some(proto::channel_member::Kind::Member) => {} Some(proto::channel_member::Kind::Invitee) => self From 7a04ee3b71da58f73a858cb676c7bb9809b822f4 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 3 Aug 2023 18:31:00 -0700 Subject: [PATCH 039/105] Start work on exposing which channels the user has admin rights to --- crates/client/src/channel_store.rs | 4 +++ crates/client/src/channel_store_tests.rs | 21 +++++++------ crates/collab/src/db.rs | 39 ++++++++++++++++-------- crates/collab/src/db/tests.rs | 15 ++++++--- crates/collab/src/rpc.rs | 13 +++++--- crates/collab/src/tests/channel_tests.rs | 9 +++--- crates/rpc/proto/zed.proto | 3 +- 7 files changed, 69 insertions(+), 35 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 1d3bbd4435e70f159a980cac9525593057cf1020..ee04865e509ca2162910e27d8cd47ea6f6d487ef 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -26,6 +26,7 @@ pub struct Channel { pub id: ChannelId, pub name: String, pub parent_id: Option, + pub user_is_admin: bool, pub depth: usize, } @@ -247,6 +248,7 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, + user_is_admin: false, parent_id: None, depth: 0, }), @@ -267,6 +269,7 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, + user_is_admin: channel.user_is_admin, parent_id: Some(parent_id), depth, }), @@ -278,6 +281,7 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, + user_is_admin: channel.user_is_admin, parent_id: None, depth: 0, }), diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs index 0d4ec6ce3589ee1cf007b30c8dbc9871c98b447b..7f31243dadc18b635cf8e804bbe49093fd25e21f 100644 --- a/crates/client/src/channel_store_tests.rs +++ b/crates/client/src/channel_store_tests.rs @@ -18,11 +18,13 @@ fn test_update_channels(cx: &mut AppContext) { id: 1, name: "b".to_string(), parent_id: None, + user_is_admin: true, }, proto::Channel { id: 2, name: "a".to_string(), parent_id: None, + user_is_admin: false, }, ], ..Default::default() @@ -33,8 +35,8 @@ fn test_update_channels(cx: &mut AppContext) { &channel_store, &[ // - (0, "a"), - (0, "b"), + (0, "a", true), + (0, "b", false), ], cx, ); @@ -47,11 +49,13 @@ fn test_update_channels(cx: &mut AppContext) { id: 3, name: "x".to_string(), parent_id: Some(1), + user_is_admin: false, }, proto::Channel { id: 4, name: "y".to_string(), parent_id: Some(2), + user_is_admin: false, }, ], ..Default::default() @@ -61,11 +65,10 @@ fn test_update_channels(cx: &mut AppContext) { assert_channels( &channel_store, &[ - // - (0, "a"), - (1, "y"), - (0, "b"), - (1, "x"), + (0, "a", true), + (1, "y", true), + (0, "b", false), + (1, "x", false), ], cx, ); @@ -81,14 +84,14 @@ fn update_channels( fn assert_channels( channel_store: &ModelHandle, - expected_channels: &[(usize, &str)], + expected_channels: &[(usize, &str, bool)], cx: &AppContext, ) { channel_store.read_with(cx, |store, _| { let actual = store .channels() .iter() - .map(|c| (c.depth, c.name.as_str())) + .map(|c| (c.depth, c.name.as_str(), c.user_is_admin)) .collect::>(); assert_eq!(actual, expected_channels); }); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 5a2ab24b1e22e2661e8c02af5f9a63145dc6fc71..6ebf5933df4a7709be4cf0bbf61a0b68019150da 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3385,6 +3385,7 @@ impl Database { .map(|channel| Channel { id: channel.id, name: channel.name, + user_is_admin: false, parent_id: None, }) .collect(); @@ -3401,20 +3402,21 @@ impl Database { self.transaction(|tx| async move { let tx = tx; - let starting_channel_ids: Vec = channel_member::Entity::find() + let channel_memberships = channel_member::Entity::find() .filter( channel_member::Column::UserId .eq(user_id) .and(channel_member::Column::Accepted.eq(true)), ) - .select_only() - .column(channel_member::Column::ChannelId) - .into_values::<_, QueryChannelIds>() .all(&*tx) .await?; + let admin_channel_ids = channel_memberships + .iter() + .filter_map(|m| m.admin.then_some(m.channel_id)) + .collect::>(); let parents_by_child_id = self - .get_channel_descendants(starting_channel_ids, &*tx) + .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx) .await?; let mut channels = Vec::with_capacity(parents_by_child_id.len()); @@ -3428,6 +3430,7 @@ impl Database { channels.push(Channel { id: row.id, name: row.name, + user_is_admin: admin_channel_ids.contains(&row.id), parent_id: parents_by_child_id.get(&row.id).copied().flatten(), }); } @@ -3627,7 +3630,7 @@ impl Database { r#" WITH RECURSIVE channel_tree(child_id, parent_id) AS ( SELECT root_ids.column1 as child_id, CAST(NULL as INTEGER) as parent_id - FROM (VALUES {}) as root_ids + FROM (VALUES {values}) as root_ids UNION SELECT channel_parents.child_id, channel_parents.parent_id FROM channel_parents, channel_tree @@ -3637,7 +3640,6 @@ impl Database { FROM channel_tree ORDER BY child_id, parent_id IS NOT NULL "#, - values ); #[derive(FromQueryResult, Debug, PartialEq)] @@ -3663,14 +3665,29 @@ impl Database { Ok(parents_by_child_id) } - pub async fn get_channel(&self, channel_id: ChannelId) -> Result> { + pub async fn get_channel( + &self, + channel_id: ChannelId, + user_id: UserId, + ) -> Result> { self.transaction(|tx| async move { let tx = tx; let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?; + let user_is_admin = channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(user_id)) + .and(channel_member::Column::Admin.eq(true)), + ) + .count(&*tx) + .await? + > 0; Ok(channel.map(|channel| Channel { id: channel.id, name: channel.name, + user_is_admin, parent_id: None, })) }) @@ -3942,6 +3959,7 @@ pub struct NewUserResult { pub struct Channel { pub id: ChannelId, pub name: String, + pub user_is_admin: bool, pub parent_id: Option, } @@ -4199,11 +4217,6 @@ pub struct WorktreeSettingsFile { pub content: String, } -#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] -enum QueryChannelIds { - ChannelId, -} - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryUserIds { UserId, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index b4c22430e5bdd955989429f2d514da3dd6d0bff0..5ffcd127768527840886a943aa5eaf1b9d60740e 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -960,43 +960,50 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { id: zed_id, name: "zed".to_string(), parent_id: None, + user_is_admin: true, }, Channel { id: crdb_id, name: "crdb".to_string(), parent_id: Some(zed_id), + user_is_admin: true, }, Channel { id: livestreaming_id, name: "livestreaming".to_string(), parent_id: Some(zed_id), + user_is_admin: true, }, Channel { id: replace_id, name: "replace".to_string(), parent_id: Some(zed_id), + user_is_admin: true, }, Channel { id: rust_id, name: "rust".to_string(), parent_id: None, + user_is_admin: true, }, Channel { id: cargo_id, name: "cargo".to_string(), parent_id: Some(rust_id), + user_is_admin: true, }, Channel { id: cargo_ra_id, name: "cargo-ra".to_string(), parent_id: Some(cargo_id), + user_is_admin: true, } ] ); // Remove a single channel db.remove_channel(crdb_id, a_id).await.unwrap(); - assert!(db.get_channel(crdb_id).await.unwrap().is_none()); + assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none()); // Remove a channel tree let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap(); @@ -1004,9 +1011,9 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]); assert_eq!(user_ids, &[a_id]); - assert!(db.get_channel(rust_id).await.unwrap().is_none()); - assert!(db.get_channel(cargo_id).await.unwrap().is_none()); - assert!(db.get_channel(cargo_ra_id).await.unwrap().is_none()); + assert!(db.get_channel(rust_id, a_id).await.unwrap().is_none()); + assert!(db.get_channel(cargo_id, a_id).await.unwrap().is_none()); + assert!(db.get_channel(cargo_ra_id, a_id).await.unwrap().is_none()); }); test_both_dbs!( diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 17f13345444c216f803892bb113ec9944b2de5ee..31b0b2280a30e58f713f79e0ed524472dcf2d095 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2150,6 +2150,7 @@ async fn create_channel( id: id.to_proto(), name: request.name, parent_id: request.parent_id, + user_is_admin: true, }); if let Some(parent_id) = parent_id { @@ -2204,7 +2205,7 @@ async fn invite_channel_member( let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); let channel = db - .get_channel(channel_id) + .get_channel(channel_id, session.user_id) .await? .ok_or_else(|| anyhow!("channel not found"))?; let invitee_id = UserId::from_proto(request.user_id); @@ -2216,6 +2217,7 @@ async fn invite_channel_member( id: channel.id.to_proto(), name: channel.name, parent_id: None, + user_is_admin: false, }); for connection_id in session .connection_pool() @@ -2264,12 +2266,12 @@ async fn respond_to_channel_invite( ) -> Result<()> { let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); + db.respond_to_channel_invite(channel_id, session.user_id, request.accept) + .await?; let channel = db - .get_channel(channel_id) + .get_channel(channel_id, session.user_id) .await? .ok_or_else(|| anyhow!("no such channel"))?; - db.respond_to_channel_invite(channel_id, session.user_id, request.accept) - .await?; let mut update = proto::UpdateChannels::default(); update @@ -2279,6 +2281,7 @@ async fn respond_to_channel_invite( update.channels.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, + user_is_admin: channel.user_is_admin, parent_id: None, }); } @@ -2430,6 +2433,7 @@ fn build_initial_channels_update( update.channels.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, + user_is_admin: channel.user_is_admin, parent_id: channel.parent_id.map(|id| id.to_proto()), }); } @@ -2447,6 +2451,7 @@ fn build_initial_channels_update( update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, + user_is_admin: false, parent_id: None, }); } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index b4f8477a2d27336099c39878d7770afa66c6c3bd..abaedb52a88a329e526842a6b4142fa591703a62 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,13 +1,10 @@ +use crate::tests::{room_participants, RoomParticipants, TestServer}; use call::ActiveCall; use client::{Channel, User}; use gpui::{executor::Deterministic, TestAppContext}; use rpc::proto; use std::sync::Arc; -use crate::tests::{room_participants, RoomParticipants}; - -use super::TestServer; - #[gpui::test] async fn test_basic_channels( deterministic: Arc, @@ -35,6 +32,7 @@ async fn test_basic_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, + user_is_admin: true, depth: 0, })] ) @@ -69,6 +67,7 @@ async fn test_basic_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, + user_is_admin: false, depth: 0, })] ) @@ -111,6 +110,7 @@ async fn test_basic_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, + user_is_admin: false, depth: 0, })] ) @@ -204,6 +204,7 @@ async fn test_channel_room( id: zed_id, name: "zed".to_string(), parent_id: None, + user_is_admin: false, depth: 0, })] ) diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 602b34529e7a1fc73c8c0264f9cac975e40d7be6..7dd5a0a8934fdab655b472435f464cd214a5958a 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1295,7 +1295,8 @@ message Nonce { message Channel { uint64 id = 1; string name = 2; - optional uint64 parent_id = 3; + bool user_is_admin = 3; + optional uint64 parent_id = 4; } message Contact { From 1762d2c6d43651edcb113b97e3609de545a796f8 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 4 Aug 2023 09:51:37 -0700 Subject: [PATCH 040/105] Add test assertion where user is not admin of channel --- crates/collab/src/db/tests.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 5ffcd127768527840886a943aa5eaf1b9d60740e..3067fd063e04de419400fe0b7894578ce277cb81 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -952,7 +952,6 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .unwrap(); let (channels, _) = db.get_channels_for_user(a_id).await.unwrap(); - assert_eq!( channels, vec![ @@ -1001,6 +1000,37 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { ] ); + let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); + assert_eq!( + channels, + vec![ + Channel { + id: zed_id, + name: "zed".to_string(), + parent_id: None, + user_is_admin: true, + }, + Channel { + id: crdb_id, + name: "crdb".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + Channel { + id: livestreaming_id, + name: "livestreaming".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + Channel { + id: replace_id, + name: "replace".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + ] + ); + // Remove a single channel db.remove_channel(crdb_id, a_id).await.unwrap(); assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none()); From a2486de04502226bc15f5495b61a0aebbca0d835 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 4 Aug 2023 09:58:10 -0700 Subject: [PATCH 041/105] Don't expose channel admin actions in UI if user isn't admin --- crates/client/src/channel_store.rs | 5 +- crates/collab/src/rpc.rs | 29 ++++--- crates/collab/src/tests/channel_tests.rs | 105 +++++++++++++++++++---- crates/collab_ui/src/collab_panel.rs | 28 +++--- 4 files changed, 123 insertions(+), 44 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index ee04865e509ca2162910e27d8cd47ea6f6d487ef..c04b123acf2aef5f64ef07de613d6f52a51454af 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -263,13 +263,14 @@ impl ChannelStore { if let Some(parent_id) = channel.parent_id { if let Some(ix) = self.channels.iter().position(|c| c.id == parent_id) { - let depth = self.channels[ix].depth + 1; + let parent_channel = &self.channels[ix]; + let depth = parent_channel.depth + 1; self.channels.insert( ix + 1, Arc::new(Channel { id: channel.id, name: channel.name, - user_is_admin: channel.user_is_admin, + user_is_admin: channel.user_is_admin || parent_channel.user_is_admin, parent_id: Some(parent_id), depth, }), diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 31b0b2280a30e58f713f79e0ed524472dcf2d095..6893c4bde4c862d6139a76b0b27c935e75440766 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2209,7 +2209,7 @@ async fn invite_channel_member( .await? .ok_or_else(|| anyhow!("channel not found"))?; let invitee_id = UserId::from_proto(request.user_id); - db.invite_channel_member(channel_id, invitee_id, session.user_id, false) + db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) .await?; let mut update = proto::UpdateChannels::default(); @@ -2268,22 +2268,29 @@ async fn respond_to_channel_invite( let channel_id = ChannelId::from_proto(request.channel_id); db.respond_to_channel_invite(channel_id, session.user_id, request.accept) .await?; - let channel = db - .get_channel(channel_id, session.user_id) - .await? - .ok_or_else(|| anyhow!("no such channel"))?; let mut update = proto::UpdateChannels::default(); update .remove_channel_invitations .push(channel_id.to_proto()); if request.accept { - update.channels.push(proto::Channel { - id: channel.id.to_proto(), - name: channel.name, - user_is_admin: channel.user_is_admin, - parent_id: None, - }); + let (channels, participants) = db.get_channels_for_user(session.user_id).await?; + update + .channels + .extend(channels.into_iter().map(|channel| proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + user_is_admin: channel.user_is_admin, + parent_id: channel.parent_id.map(ChannelId::to_proto), + })); + update + .channel_participants + .extend(participants.into_iter().map(|(channel_id, user_ids)| { + proto::ChannelParticipants { + channel_id: channel_id.to_proto(), + participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(), + } + })); } session.peer.send(session.connection_id, update)?; response.send(proto::Ack {})?; diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index abaedb52a88a329e526842a6b4142fa591703a62..43e5a296c44433e0389fcc2138fc0fcc5589b4ca 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -23,18 +23,34 @@ async fn test_basic_channels( }) .await .unwrap(); + let channel_b_id = client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.create_channel("channel-b", Some(channel_a_id)) + }) + .await + .unwrap(); deterministic.run_until_parked(); client_a.channel_store().read_with(cx_a, |channels, _| { assert_eq!( channels.channels(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - user_is_admin: true, - depth: 0, - })] + &[ + Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + }), + Arc::new(Channel { + id: channel_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: true, + depth: 1, + }) + ] ) }); @@ -48,7 +64,7 @@ async fn test_basic_channels( .update(cx_a, |store, cx| { assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); - let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx); + let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), true, cx); // Make sure we're synchronously storing the pending invite assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); @@ -57,9 +73,8 @@ async fn test_basic_channels( .await .unwrap(); - // Wait for client b to see the invitation + // Client A sees that B has been invited. deterministic.run_until_parked(); - client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!( channels.channel_invitations(), @@ -69,7 +84,7 @@ async fn test_basic_channels( parent_id: None, user_is_admin: false, depth: 0, - })] + }),] ) }); let members = client_a @@ -94,7 +109,7 @@ async fn test_basic_channels( ], ); - // Client B now sees that they are a member channel A. + // Client B accepts the invitation. client_b .channel_store() .update(cx_b, |channels, _| { @@ -102,17 +117,69 @@ async fn test_basic_channels( }) .await .unwrap(); + + // Client B now sees that they are a member of channel A and its existing + // subchannels. Their admin priveleges extend to subchannels of channel A. + deterministic.run_until_parked(); client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!(channels.channel_invitations(), &[]); assert_eq!( channels.channels(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - user_is_admin: false, - depth: 0, - })] + &[ + Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + }), + Arc::new(Channel { + id: channel_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: true, + depth: 1, + }) + ] + ) + }); + + let channel_c_id = client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.create_channel("channel-c", Some(channel_a_id)) + }) + .await + .unwrap(); + + // TODO - ensure sibling channels are sorted in a stable way + deterministic.run_until_parked(); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channels(), + &[ + Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + }), + Arc::new(Channel { + id: channel_c_id, + name: "channel-c".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: true, + depth: 1, + }), + Arc::new(Channel { + id: channel_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: true, + depth: 1, + }), + ] ) }); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 771927c8ac4c45b9b22b06ae17e27d45854e15e3..df27ea50059247898b5a86df8430605bceef93d0 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1509,18 +1509,22 @@ impl CollabPanel { channel_id: u64, cx: &mut ViewContext, ) { - self.context_menu.update(cx, |context_menu, cx| { - context_menu.show( - position, - gpui::elements::AnchorCorner::BottomLeft, - vec![ - ContextMenuItem::action("New Channel", NewChannel { channel_id }), - ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), - ContextMenuItem::action("Add member", AddMember { channel_id }), - ], - cx, - ); - }); + if let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) { + if channel.user_is_admin { + self.context_menu.update(cx, |context_menu, cx| { + context_menu.show( + position, + gpui::elements::AnchorCorner::BottomLeft, + vec![ + ContextMenuItem::action("New Channel", NewChannel { channel_id }), + ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), + ContextMenuItem::action("Add member", AddMember { channel_id }), + ], + cx, + ); + }); + } + } } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { From 87b2d599c187e6cd4fb3a341ddf3101fb0872f3c Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 4 Aug 2023 14:12:08 -0700 Subject: [PATCH 042/105] Flesh out channel member management Co-authored-by: Mikayla --- assets/icons/channels.svg | 6 + assets/keymaps/default.json | 8 + crates/client/src/channel_store.rs | 66 +++- crates/collab/src/db.rs | 56 ++- crates/collab/src/db/tests.rs | 46 ++- crates/collab/src/rpc.rs | 73 +++- crates/collab/src/tests/channel_tests.rs | 136 +++++-- crates/collab_ui/src/collab_panel.rs | 42 +- .../src/collab_panel/channel_modal.rs | 358 +++++++++++++----- crates/rpc/proto/zed.proto | 10 +- crates/rpc/src/proto.rs | 2 + crates/theme/src/theme.rs | 8 +- styles/src/style_tree/channel_modal.ts | 55 +++ 13 files changed, 699 insertions(+), 167 deletions(-) create mode 100644 assets/icons/channels.svg diff --git a/assets/icons/channels.svg b/assets/icons/channels.svg new file mode 100644 index 0000000000000000000000000000000000000000..edd04626782e52bc2f3c1a73a08f2de166828c33 --- /dev/null +++ b/assets/icons/channels.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index a7f4b5508467642b9ba411336907cd1d8df4d58b..d99a6608504aec59617a4d644088a1e54d0db31f 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -550,6 +550,14 @@ "alt-shift-f": "project_panel::NewSearchInDirectory" } }, + { + "context": "ChannelModal", + "bindings": { + "left": "channel_modal::SelectNextControl", + "right": "channel_modal::SelectNextControl", + "tab": "channel_modal::ToggleMode" + } + }, { "context": "Terminal", "bindings": { diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index c04b123acf2aef5f64ef07de613d6f52a51454af..51176986ef44d5ac4e64843afb85a6269aff4c41 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -30,6 +30,12 @@ pub struct Channel { pub depth: usize, } +pub struct ChannelMembership { + pub user: Arc, + pub kind: proto::channel_member::Kind, + pub admin: bool, +} + impl Entity for ChannelStore { type Event = (); } @@ -72,6 +78,20 @@ impl ChannelStore { self.channels.iter().find(|c| c.id == channel_id).cloned() } + pub fn is_user_admin(&self, mut channel_id: ChannelId) -> bool { + while let Some(channel) = self.channel_for_id(channel_id) { + if channel.user_is_admin { + return true; + } + if let Some(parent_id) = channel.parent_id { + channel_id = parent_id; + } else { + break; + } + } + false + } + pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc] { self.channel_participants .get(&channel_id) @@ -149,6 +169,35 @@ impl ChannelStore { }) } + pub fn set_member_admin( + &mut self, + channel_id: ChannelId, + user_id: UserId, + admin: bool, + cx: &mut ModelContext, + ) -> Task> { + if !self.outgoing_invites.insert((channel_id, user_id)) { + return Task::ready(Err(anyhow!("member request already in progress"))); + } + + cx.notify(); + let client = self.client.clone(); + cx.spawn(|this, mut cx| async move { + client + .request(proto::SetChannelMemberAdmin { + channel_id, + user_id, + admin, + }) + .await?; + this.update(&mut cx, |this, cx| { + this.outgoing_invites.remove(&(channel_id, user_id)); + cx.notify(); + }); + Ok(()) + }) + } + pub fn respond_to_channel_invite( &mut self, channel_id: ChannelId, @@ -167,7 +216,7 @@ impl ChannelStore { &self, channel_id: ChannelId, cx: &mut ModelContext, - ) -> Task, proto::channel_member::Kind)>>> { + ) -> Task>> { let client = self.client.clone(); let user_store = self.user_store.downgrade(); cx.spawn(|_, mut cx| async move { @@ -187,7 +236,11 @@ impl ChannelStore { .into_iter() .zip(response.members) .filter_map(|(user, member)| { - Some((user, proto::channel_member::Kind::from_i32(member.kind)?)) + Some(ChannelMembership { + user, + admin: member.admin, + kind: proto::channel_member::Kind::from_i32(member.kind)?, + }) }) .collect()) }) @@ -239,7 +292,8 @@ impl ChannelStore { .iter_mut() .find(|c| c.id == channel.id) { - Arc::make_mut(existing_channel).name = channel.name; + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; continue; } @@ -257,7 +311,9 @@ impl ChannelStore { for channel in payload.channels { if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { - Arc::make_mut(existing_channel).name = channel.name; + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; + existing_channel.user_is_admin = channel.user_is_admin; continue; } @@ -270,7 +326,7 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, - user_is_admin: channel.user_is_admin || parent_channel.user_is_admin, + user_is_admin: channel.user_is_admin, parent_id: Some(parent_id), depth, }), diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 6ebf5933df4a7709be4cf0bbf61a0b68019150da..9dc4ad805b9aed614f2d8c14f95f20c04e05d87c 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3243,8 +3243,9 @@ impl Database { } } + let channel_ancestors = self.get_channel_ancestors(channel_id, &*tx).await?; let members_to_notify: Vec = channel_member::Entity::find() - .filter(channel_member::Column::ChannelId.is_in(channels_to_remove.keys().copied())) + .filter(channel_member::Column::ChannelId.is_in(channel_ancestors)) .select_only() .column(channel_member::Column::UserId) .distinct() @@ -3472,6 +3473,39 @@ impl Database { .await } + pub async fn set_channel_member_admin( + &self, + channel_id: ChannelId, + from: UserId, + for_user: UserId, + admin: bool, + ) -> Result<()> { + self.transaction(|tx| async move { + self.check_user_is_channel_admin(channel_id, from, &*tx) + .await?; + + let result = channel_member::Entity::update_many() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(for_user)), + ) + .set(channel_member::ActiveModel { + admin: ActiveValue::set(admin), + ..Default::default() + }) + .exec(&*tx) + .await?; + + if result.rows_affected == 0 { + Err(anyhow!("no such member"))?; + } + + Ok(()) + }) + .await + } + pub async fn get_channel_member_details( &self, channel_id: ChannelId, @@ -3484,6 +3518,7 @@ impl Database { #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryMemberDetails { UserId, + Admin, IsDirectMember, Accepted, } @@ -3495,6 +3530,7 @@ impl Database { .filter(channel_member::Column::ChannelId.is_in(ancestor_ids.iter().copied())) .select_only() .column(channel_member::Column::UserId) + .column(channel_member::Column::Admin) .column_as( channel_member::Column::ChannelId.eq(channel_id), QueryMemberDetails::IsDirectMember, @@ -3507,7 +3543,12 @@ impl Database { let mut rows = Vec::::new(); while let Some(row) = stream.next().await { - let (user_id, is_direct_member, is_invite_accepted): (UserId, bool, bool) = row?; + let (user_id, is_admin, is_direct_member, is_invite_accepted): ( + UserId, + bool, + bool, + bool, + ) = row?; let kind = match (is_direct_member, is_invite_accepted) { (true, true) => proto::channel_member::Kind::Member, (true, false) => proto::channel_member::Kind::Invitee, @@ -3518,11 +3559,18 @@ impl Database { let kind = kind.into(); if let Some(last_row) = rows.last_mut() { if last_row.user_id == user_id { - last_row.kind = last_row.kind.min(kind); + if is_direct_member { + last_row.kind = kind; + last_row.admin = is_admin; + } continue; } } - rows.push(proto::ChannelMember { user_id, kind }); + rows.push(proto::ChannelMember { + user_id, + kind, + admin: is_admin, + }); } Ok(rows) diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 3067fd063e04de419400fe0b7894578ce277cb81..efc35a5c249f6ab7ea1081665cd9c4196f0f482f 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -915,7 +915,7 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap(); - db.invite_channel_member(zed_id, b_id, a_id, true) + db.invite_channel_member(zed_id, b_id, a_id, false) .await .unwrap(); @@ -1000,6 +1000,43 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { ] ); + let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); + assert_eq!( + channels, + vec![ + Channel { + id: zed_id, + name: "zed".to_string(), + parent_id: None, + user_is_admin: false, + }, + Channel { + id: crdb_id, + name: "crdb".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + Channel { + id: livestreaming_id, + name: "livestreaming".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + Channel { + id: replace_id, + name: "replace".to_string(), + parent_id: Some(zed_id), + user_is_admin: false, + }, + ] + ); + + // Update member permissions + let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await; + assert!(set_subchannel_admin.is_err()); + let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await; + assert!(set_channel_admin.is_ok()); + let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); assert_eq!( channels, @@ -1176,7 +1213,7 @@ test_both_dbs!( db.invite_channel_member(channel_1_2, user_2, user_1, false) .await .unwrap(); - db.invite_channel_member(channel_1_1, user_3, user_1, false) + db.invite_channel_member(channel_1_1, user_3, user_1, true) .await .unwrap(); @@ -1210,14 +1247,17 @@ test_both_dbs!( proto::ChannelMember { user_id: user_1.to_proto(), kind: proto::channel_member::Kind::Member.into(), + admin: true, }, proto::ChannelMember { user_id: user_2.to_proto(), kind: proto::channel_member::Kind::Invitee.into(), + admin: false, }, proto::ChannelMember { user_id: user_3.to_proto(), kind: proto::channel_member::Kind::Invitee.into(), + admin: true, }, ] ); @@ -1241,10 +1281,12 @@ test_both_dbs!( proto::ChannelMember { user_id: user_1.to_proto(), kind: proto::channel_member::Kind::Member.into(), + admin: true, }, proto::ChannelMember { user_id: user_2.to_proto(), kind: proto::channel_member::Kind::AncestorMember.into(), + admin: false, }, ] ); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 6893c4bde4c862d6139a76b0b27c935e75440766..f1fd97db415b31b11e3b1219bae8f8313fc4b163 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -246,6 +246,7 @@ impl Server { .add_request_handler(remove_channel) .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) + .add_request_handler(set_channel_member_admin) .add_request_handler(get_channel_members) .add_request_handler(respond_to_channel_invite) .add_request_handler(join_channel) @@ -2150,19 +2151,24 @@ async fn create_channel( id: id.to_proto(), name: request.name, parent_id: request.parent_id, - user_is_admin: true, + user_is_admin: false, }); - if let Some(parent_id) = parent_id { - let member_ids = db.get_channel_members(parent_id).await?; - let connection_pool = session.connection_pool().await; - for member_id in member_ids { - for connection_id in connection_pool.user_connection_ids(member_id) { - session.peer.send(connection_id, update.clone())?; + let user_ids_to_notify = if let Some(parent_id) = parent_id { + db.get_channel_members(parent_id).await? + } else { + vec![session.user_id] + }; + + let connection_pool = session.connection_pool().await; + for user_id in user_ids_to_notify { + for connection_id in connection_pool.user_connection_ids(user_id) { + let mut update = update.clone(); + if user_id == session.user_id { + update.channels[0].user_is_admin = true; } + session.peer.send(connection_id, update)?; } - } else { - session.peer.send(session.connection_id, update)?; } Ok(()) @@ -2239,8 +2245,57 @@ async fn remove_channel_member( let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); let member_id = UserId::from_proto(request.user_id); + db.remove_channel_member(channel_id, member_id, session.user_id) .await?; + + let mut update = proto::UpdateChannels::default(); + update.remove_channels.push(channel_id.to_proto()); + + for connection_id in session + .connection_pool() + .await + .user_connection_ids(member_id) + { + session.peer.send(connection_id, update.clone())?; + } + + response.send(proto::Ack {})?; + Ok(()) +} + +async fn set_channel_member_admin( + request: proto::SetChannelMemberAdmin, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let member_id = UserId::from_proto(request.user_id); + db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin) + .await?; + + let channel = db + .get_channel(channel_id, member_id) + .await? + .ok_or_else(|| anyhow!("channel not found"))?; + + let mut update = proto::UpdateChannels::default(); + update.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + user_is_admin: request.admin, + }); + + for connection_id in session + .connection_pool() + .await + .user_connection_ids(member_id) + { + session.peer.send(connection_id, update.clone())?; + } + response.send(proto::Ack {})?; Ok(()) } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 43e5a296c44433e0389fcc2138fc0fcc5589b4ca..ae149f6a8aa03e81e378e77bae84d53bc610aed2 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,12 +1,12 @@ use crate::tests::{room_participants, RoomParticipants, TestServer}; use call::ActiveCall; -use client::{Channel, User}; +use client::{Channel, ChannelMembership, User}; use gpui::{executor::Deterministic, TestAppContext}; use rpc::proto; use std::sync::Arc; #[gpui::test] -async fn test_basic_channels( +async fn test_core_channels( deterministic: Arc, cx_a: &mut TestAppContext, cx_b: &mut TestAppContext, @@ -64,7 +64,7 @@ async fn test_basic_channels( .update(cx_a, |store, cx| { assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); - let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), true, cx); + let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx); // Make sure we're synchronously storing the pending invite assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap())); @@ -84,7 +84,7 @@ async fn test_basic_channels( parent_id: None, user_is_admin: false, depth: 0, - }),] + })] ) }); let members = client_a @@ -100,10 +100,12 @@ async fn test_basic_channels( &[ ( client_a.user_id().unwrap(), + true, proto::channel_member::Kind::Member, ), ( client_b.user_id().unwrap(), + false, proto::channel_member::Kind::Invitee, ), ], @@ -117,10 +119,9 @@ async fn test_basic_channels( }) .await .unwrap(); - - // Client B now sees that they are a member of channel A and its existing - // subchannels. Their admin priveleges extend to subchannels of channel A. deterministic.run_until_parked(); + + // Client B now sees that they are a member of channel A and its existing subchannels. client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!(channels.channel_invitations(), &[]); assert_eq!( @@ -130,14 +131,14 @@ async fn test_basic_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, + user_is_admin: false, depth: 0, }), Arc::new(Channel { id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: true, + user_is_admin: false, depth: 1, }) ] @@ -147,12 +148,11 @@ async fn test_basic_channels( let channel_c_id = client_a .channel_store() .update(cx_a, |channel_store, _| { - channel_store.create_channel("channel-c", Some(channel_a_id)) + channel_store.create_channel("channel-c", Some(channel_b_id)) }) .await .unwrap(); - // TODO - ensure sibling channels are sorted in a stable way deterministic.run_until_parked(); client_b.channel_store().read_with(cx_b, |channels, _| { assert_eq!( @@ -162,40 +162,132 @@ async fn test_basic_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, + user_is_admin: false, depth: 0, }), + Arc::new(Channel { + id: channel_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: false, + depth: 1, + }), Arc::new(Channel { id: channel_c_id, name: "channel-c".to_string(), - parent_id: Some(channel_a_id), + parent_id: Some(channel_b_id), + user_is_admin: false, + depth: 2, + }), + ] + ) + }); + + // Update client B's membership to channel A to be an admin. + client_a + .channel_store() + .update(cx_a, |store, cx| { + store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + + // Observe that client B is now an admin of channel A, and that + // their admin priveleges extend to subchannels of channel A. + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!(channels.channel_invitations(), &[]); + assert_eq!( + channels.channels(), + &[ + Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, user_is_admin: true, - depth: 1, + depth: 0, }), Arc::new(Channel { id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: true, + user_is_admin: false, depth: 1, }), + Arc::new(Channel { + id: channel_c_id, + name: "channel-c".to_string(), + parent_id: Some(channel_b_id), + user_is_admin: false, + depth: 2, + }), ] - ) + ); + + assert!(channels.is_user_admin(channel_c_id)) }); - // Client A deletes the channel + // Client A deletes the channel, deletion also deletes subchannels. client_a .channel_store() .update(cx_a, |channel_store, _| { - channel_store.remove_channel(channel_a_id) + channel_store.remove_channel(channel_b_id) }) .await .unwrap(); deterministic.run_until_parked(); + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })] + ) + }); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })] + ) + }); + + // Remove client B client_a .channel_store() - .read_with(cx_a, |channels, _| assert_eq!(channels.channels(), &[])); + .update(cx_a, |channel_store, cx| { + channel_store.remove_member(channel_a_id, client_b.user_id().unwrap(), cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + // Client A still has their channel + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })] + ) + }); + + // Client B is gone client_b .channel_store() .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); @@ -209,13 +301,13 @@ fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u } fn assert_members_eq( - members: &[(Arc, proto::channel_member::Kind)], - expected_members: &[(u64, proto::channel_member::Kind)], + members: &[ChannelMembership], + expected_members: &[(u64, bool, proto::channel_member::Kind)], ) { assert_eq!( members .iter() - .map(|(user, status)| (user.id, *status)) + .map(|member| (member.user.id, member.admin, member.kind)) .collect::>(), expected_members ); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index df27ea50059247898b5a86df8430605bceef93d0..a84c5c111eb50082feb3d148cfe6ab27c6e9ded9 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -41,8 +41,7 @@ use workspace::{ }; use crate::face_pile::FacePile; - -use self::channel_modal::build_channel_modal; +use channel_modal::ChannelModal; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { @@ -1284,7 +1283,14 @@ impl CollabPanel { let channel_id = channel.id; MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() - .with_child(Svg::new("icons/file_icons/hash.svg").aligned().left()) + .with_child( + Svg::new("icons/channels.svg") + .with_color(theme.add_channel_button.color) + .constrained() + .with_width(14.) + .aligned() + .left(), + ) .with_child( Label::new(channel.name.clone(), theme.contact_username.text.clone()) .contained() @@ -1509,21 +1515,19 @@ impl CollabPanel { channel_id: u64, cx: &mut ViewContext, ) { - if let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id) { - if channel.user_is_admin { - self.context_menu.update(cx, |context_menu, cx| { - context_menu.show( - position, - gpui::elements::AnchorCorner::BottomLeft, - vec![ - ContextMenuItem::action("New Channel", NewChannel { channel_id }), - ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), - ContextMenuItem::action("Add member", AddMember { channel_id }), - ], - cx, - ); - }); - } + if self.channel_store.read(cx).is_user_admin(channel_id) { + self.context_menu.update(cx, |context_menu, cx| { + context_menu.show( + position, + gpui::elements::AnchorCorner::BottomLeft, + vec![ + ContextMenuItem::action("New Channel", NewChannel { channel_id }), + ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), + ContextMenuItem::action("Add member", AddMember { channel_id }), + ], + cx, + ); + }); } } @@ -1697,7 +1701,7 @@ impl CollabPanel { workspace.update(&mut cx, |workspace, cx| { workspace.toggle_modal(cx, |_, cx| { cx.add_view(|cx| { - build_channel_modal( + ChannelModal::new( user_store.clone(), channel_store.clone(), channel_id, diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 56285400221203c66d399be99ca98c2d8471872c..0286e30b808a0c54306ca6c4776f21434557b553 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -1,6 +1,7 @@ -use client::{proto, ChannelId, ChannelStore, User, UserId, UserStore}; +use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ + actions, elements::*, platform::{CursorStyle, MouseButton}, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle, @@ -10,8 +11,12 @@ use std::sync::Arc; use util::TryFutureExt; use workspace::Modal; +actions!(channel_modal, [SelectNextControl, ToggleMode]); + pub fn init(cx: &mut AppContext) { Picker::::init(cx); + cx.add_action(ChannelModal::toggle_mode); + cx.add_action(ChannelModal::select_next_control); } pub struct ChannelModal { @@ -21,6 +26,110 @@ pub struct ChannelModal { has_focus: bool, } +impl ChannelModal { + pub fn new( + user_store: ModelHandle, + channel_store: ModelHandle, + channel_id: ChannelId, + mode: Mode, + members: Vec, + cx: &mut ViewContext, + ) -> Self { + cx.observe(&channel_store, |_, _, cx| cx.notify()).detach(); + let picker = cx.add_view(|cx| { + Picker::new( + ChannelModalDelegate { + 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: members + .iter() + .enumerate() + .map(|(id, member)| StringMatchCandidate { + id, + string: member.user.github_login.clone(), + char_bag: member.user.github_login.chars().collect(), + }) + .collect(), + members, + mode, + selected_column: None, + }, + cx, + ) + .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone()) + }); + + cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); + let has_focus = picker.read(cx).has_focus(); + + Self { + picker, + channel_store, + channel_id, + has_focus, + } + } + + fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext) { + 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) { + 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 members = channel_store + .update(&mut cx, |channel_store, cx| { + channel_store.get_channel_member_details(channel_id, cx) + }) + .await?; + this.update(&mut cx, |this, cx| { + this.picker + .update(cx, |picker, _| picker.delegate_mut().members = members); + })?; + } + + this.update(&mut cx, |this, cx| { + this.picker.update(cx, |picker, cx| { + let delegate = picker.delegate_mut(); + delegate.mode = mode; + picker.update_matches(picker.query(cx), cx); + cx.notify() + }); + }) + }) + .detach(); + } + + fn select_next_control(&mut self, _: &SelectNextControl, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| { + let delegate = picker.delegate_mut(); + match delegate.mode { + Mode::ManageMembers => match delegate.selected_column { + Some(UserColumn::Remove) => { + delegate.selected_column = Some(UserColumn::ToggleAdmin) + } + Some(UserColumn::ToggleAdmin) => { + delegate.selected_column = Some(UserColumn::Remove) + } + None => todo!(), + }, + Mode::InviteMembers => {} + } + cx.notify() + }); + } +} + impl Entity for ChannelModal { type Event = PickerEvent; } @@ -60,11 +169,7 @@ impl View for ChannelModal { }) .on_click(MouseButton::Left, move |_, this, cx| { if !active { - this.picker.update(cx, |picker, cx| { - picker.delegate_mut().mode = mode; - picker.update_matches(picker.query(cx), cx); - cx.notify(); - }) + this.set_mode(mode, cx); } }) .with_cursor_style(if active { @@ -125,65 +230,29 @@ impl Modal for ChannelModal { } } -pub fn build_channel_modal( - user_store: ModelHandle, - channel_store: ModelHandle, - channel_id: ChannelId, - mode: Mode, - members: Vec<(Arc, proto::channel_member::Kind)>, - cx: &mut ViewContext, -) -> ChannelModal { - let picker = cx.add_view(|cx| { - Picker::new( - ChannelModalDelegate { - matches: Vec::new(), - selected_index: 0, - user_store: user_store.clone(), - channel_store: channel_store.clone(), - channel_id, - match_candidates: members - .iter() - .enumerate() - .map(|(id, member)| StringMatchCandidate { - id, - string: member.0.github_login.clone(), - char_bag: member.0.github_login.chars().collect(), - }) - .collect(), - members, - mode, - }, - cx, - ) - .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone()) - }); - - cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); - let has_focus = picker.read(cx).has_focus(); - - ChannelModal { - picker, - channel_store, - channel_id, - has_focus, - } -} - #[derive(Copy, Clone, PartialEq)] pub enum Mode { ManageMembers, InviteMembers, } +#[derive(Copy, Clone, PartialEq)] +pub enum UserColumn { + ToggleAdmin, + Remove, +} + pub struct ChannelModalDelegate { - matches: Vec<(Arc, Option)>, + matching_users: Vec>, + matching_member_indices: Vec, user_store: ModelHandle, channel_store: ModelHandle, channel_id: ChannelId, selected_index: usize, mode: Mode, + selected_column: Option, match_candidates: Arc<[StringMatchCandidate]>, - members: Vec<(Arc, proto::channel_member::Kind)>, + members: Vec, } impl PickerDelegate for ChannelModalDelegate { @@ -192,7 +261,10 @@ impl PickerDelegate for ChannelModalDelegate { } fn match_count(&self) -> usize { - self.matches.len() + match self.mode { + Mode::ManageMembers => self.matching_member_indices.len(), + Mode::InviteMembers => self.matching_users.len(), + } } fn selected_index(&self) -> usize { @@ -201,6 +273,10 @@ impl PickerDelegate for ChannelModalDelegate { fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { self.selected_index = ix; + self.selected_column = match self.mode { + Mode::ManageMembers => Some(UserColumn::ToggleAdmin), + Mode::InviteMembers => None, + }; } fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { @@ -220,11 +296,10 @@ impl PickerDelegate for ChannelModalDelegate { .await; picker.update(&mut cx, |picker, cx| { let delegate = picker.delegate_mut(); - delegate.matches.clear(); - delegate.matches.extend(matches.into_iter().map(|m| { - let member = &delegate.members[m.candidate_id]; - (member.0.clone(), Some(member.1)) - })); + delegate.matching_member_indices.clear(); + delegate + .matching_member_indices + .extend(matches.into_iter().map(|m| m.candidate_id)); cx.notify(); })?; anyhow::Ok(()) @@ -242,10 +317,7 @@ impl PickerDelegate for ChannelModalDelegate { let users = search_users.await?; picker.update(&mut cx, |picker, cx| { let delegate = picker.delegate_mut(); - delegate.matches.clear(); - delegate - .matches - .extend(users.into_iter().map(|user| (user, None))); + delegate.matching_users = users; cx.notify(); })?; anyhow::Ok(()) @@ -258,29 +330,23 @@ impl PickerDelegate for ChannelModalDelegate { } fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { - if let Some((user, _)) = self.matches.get(self.selected_index) { - match self.mode { - Mode::ManageMembers => self - .channel_store - .update(cx, |store, cx| { - store.remove_member(self.channel_id, user.id, cx) - }) - .detach(), - Mode::InviteMembers => match self.member_status(user.id, cx) { - Some(proto::channel_member::Kind::Member) => {} - Some(proto::channel_member::Kind::Invitee) => self - .channel_store - .update(cx, |store, cx| { - store.remove_member(self.channel_id, user.id, cx) - }) - .detach(), - Some(proto::channel_member::Kind::AncestorMember) | None => self - .channel_store + if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) { + match self.member_status(selected_user.id, cx) { + Some(proto::channel_member::Kind::Member) + | Some(proto::channel_member::Kind::Invitee) => { + if self.selected_column == Some(UserColumn::ToggleAdmin) { + self.set_member_admin(selected_user.id, !admin.unwrap_or(false), cx); + } else { + self.remove_member(selected_user.id, cx); + } + } + Some(proto::channel_member::Kind::AncestorMember) | None => { + self.channel_store .update(cx, |store, cx| { - store.invite_member(self.channel_id, user.id, false, cx) + store.invite_member(self.channel_id, selected_user.id, false, cx) }) - .detach(), - }, + .detach(); + } } } } @@ -297,17 +363,9 @@ impl PickerDelegate for ChannelModalDelegate { cx: &gpui::AppContext, ) -> AnyElement> { let theme = &theme::current(cx).collab_panel.channel_modal; - let (user, _) = &self.matches[ix]; + let (user, admin) = self.user_at_index(ix).unwrap(); let request_status = self.member_status(user.id, cx); - let icon_path = match request_status { - Some(proto::channel_member::Kind::AncestorMember) => Some("icons/check_8.svg"), - Some(proto::channel_member::Kind::Member) => Some("icons/check_8.svg"), - Some(proto::channel_member::Kind::Invitee) => Some("icons/x_mark_8.svg"), - None => None, - }; - let button_style = &theme.contact_button; - let style = theme.picker.item.in_state(selected).style_for(mouse_state); Flex::row() .with_children(user.avatar.clone().map(|avatar| { @@ -323,20 +381,69 @@ impl PickerDelegate for ChannelModalDelegate { .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() + .with_children(admin.map(|admin| { + let member_style = theme.admin_toggle_part.in_state(!admin); + let admin_style = theme.admin_toggle_part.in_state(admin); + Flex::row() + .with_child( + Label::new("member", member_style.text.clone()) + .contained() + .with_style(member_style.container), + ) + .with_child( + Label::new("admin", admin_style.text.clone()) + .contained() + .with_style(admin_style.container), + ) .contained() - .with_style(button_style.container) - .constrained() - .with_width(button_style.button_width) - .with_height(button_style.button_width) + .with_style(theme.admin_toggle) .aligned() .flex_float() })) + .with_children({ + match self.mode { + Mode::ManageMembers => match request_status { + Some(proto::channel_member::Kind::Member) => Some( + Label::new("remove member", theme.remove_member_button.text.clone()) + .contained() + .with_style(theme.remove_member_button.container) + .into_any(), + ), + Some(proto::channel_member::Kind::Invitee) => Some( + Label::new("cancel invite", theme.cancel_invite_button.text.clone()) + .contained() + .with_style(theme.cancel_invite_button.container) + .into_any(), + ), + Some(proto::channel_member::Kind::AncestorMember) | None => None, + }, + Mode::InviteMembers => { + let svg = match request_status { + Some(proto::channel_member::Kind::Member) => Some( + Svg::new("icons/check_8.svg") + .with_color(theme.member_icon.color) + .constrained() + .with_width(theme.member_icon.width) + .aligned() + .contained() + .with_style(theme.member_icon.container), + ), + Some(proto::channel_member::Kind::Invitee) => Some( + Svg::new("icons/check_8.svg") + .with_color(theme.invitee_icon.color) + .constrained() + .with_width(theme.invitee_icon.width) + .aligned() + .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() @@ -353,11 +460,56 @@ impl ChannelModalDelegate { ) -> Option { self.members .iter() - .find_map(|(user, status)| (user.id == user_id).then_some(*status)) + .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind)) .or(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, Option)> { + 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.admin), + )) + }), + Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)), + } + } + + fn set_member_admin(&mut self, user_id: u64, admin: bool, cx: &mut ViewContext>) { + let update = self.channel_store.update(cx, |store, cx| { + store.set_member_admin(self.channel_id, user_id, admin, cx) + }); + cx.spawn(|picker, mut cx| async move { + update.await?; + picker.update(&mut cx, |picker, cx| { + let this = picker.delegate_mut(); + if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) { + member.admin = admin; + } + }) + }) + .detach_and_log_err(cx); + } + + fn remove_member(&mut self, user_id: u64, cx: &mut ViewContext>) { + 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 = picker.delegate_mut(); + if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) { + this.members.remove(ix); + } + }) + }) + .detach_and_log_err(cx); + } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 7dd5a0a8934fdab655b472435f464cd214a5958a..8f187a87c6ee8b2c92af174ae6423dede91e2d40 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -140,6 +140,7 @@ message Envelope { RemoveChannel remove_channel = 127; GetChannelMembers get_channel_members = 128; GetChannelMembersResponse get_channel_members_response = 129; + SetChannelMemberAdmin set_channel_member_admin = 130; } } @@ -898,7 +899,8 @@ message GetChannelMembersResponse { message ChannelMember { uint64 user_id = 1; - Kind kind = 2; + bool admin = 2; + Kind kind = 3; enum Kind { Member = 0; @@ -927,6 +929,12 @@ message RemoveChannelMember { uint64 user_id = 2; } +message SetChannelMemberAdmin { + uint64 channel_id = 1; + uint64 user_id = 2; + bool admin = 3; +} + message RespondToChannelInvite { uint64 channel_id = 1; bool accept = 2; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index c23bbb23e4f94674079f1f93de170ddcdbb69f11..fac011f803317c7f436e14bd7e56c595347d3860 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -217,6 +217,7 @@ messages!( (JoinChannel, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), + (SetChannelMemberAdmin, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), (ShareProject, Foreground), @@ -298,6 +299,7 @@ request_messages!( (RemoveContact, Ack), (RespondToContactRequest, Ack), (RespondToChannelInvite, Ack), + (SetChannelMemberAdmin, Ack), (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8d0159d7adba6bd4b7678845b668e49db58e08f1..448f6ca5dda861a71f09a077c5883c71efb20262 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -255,8 +255,12 @@ pub struct ChannelModal { pub row_height: f32, pub contact_avatar: ImageStyle, pub contact_username: ContainerStyle, - pub contact_button: IconButton, - pub disabled_contact_button: IconButton, + pub remove_member_button: ContainedText, + pub cancel_invite_button: ContainedText, + pub member_icon: Icon, + pub invitee_icon: Icon, + pub admin_toggle: ContainerStyle, + pub admin_toggle_part: Toggleable, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 951591676b5790811a39dbac790872694bd1de1c..a097bc561f75afbfe1607e976bbb2a914a1e3b6f 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -41,6 +41,61 @@ export default function channel_modal(): any { } return { + member_icon: { + background: background(theme.middle), + padding: { + bottom: 4, + left: 4, + right: 4, + top: 4, + }, + width: 5, + color: foreground(theme.middle, "accent"), + }, + invitee_icon: { + background: background(theme.middle), + padding: { + bottom: 4, + left: 4, + right: 4, + top: 4, + }, + width: 5, + color: foreground(theme.middle, "accent"), + }, + remove_member_button: { + ...text(theme.middle, "sans", { size: "xs" }), + background: background(theme.middle), + padding: { + left: 7, + right: 7 + } + }, + cancel_invite_button: { + ...text(theme.middle, "sans", { size: "xs" }), + background: background(theme.middle), + }, + admin_toggle_part: toggleable({ + base: { + ...text(theme.middle, "sans", { size: "xs" }), + padding: { + left: 7, + right: 7, + }, + }, + state: { + active: { + background: background(theme.middle, "on"), + } + } + }), + admin_toggle: { + border: border(theme.middle, "active"), + background: background(theme.middle), + margin: { + right: 8, + } + }, container: { background: background(theme.lowest), border: border(theme.lowest), From 2ccd153233a986dbf7dabdec151a1f98d7dc2741 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 4 Aug 2023 16:14:01 -0700 Subject: [PATCH 043/105] Fix joining descendant channels, style channel invites Co-authored-by: Mikayla --- .../icons/{channels.svg => channel_hash.svg} | 0 crates/client/src/channel_store.rs | 2 +- crates/collab/src/db.rs | 34 +- crates/collab/src/tests/channel_tests.rs | 32 ++ crates/collab_ui/src/collab_panel.rs | 334 +++++++++--------- .../src/collab_panel/channel_modal.rs | 4 +- crates/theme/src/theme.rs | 8 +- styles/src/style_tree/collab_panel.ts | 35 +- 8 files changed, 258 insertions(+), 191 deletions(-) rename assets/icons/{channels.svg => channel_hash.svg} (100%) diff --git a/assets/icons/channels.svg b/assets/icons/channel_hash.svg similarity index 100% rename from assets/icons/channels.svg rename to assets/icons/channel_hash.svg diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 51176986ef44d5ac4e64843afb85a6269aff4c41..13510a1e1c899f4355e36e7ccd403fa35fb3eaa4 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -104,7 +104,7 @@ impl ChannelStore { parent_id: Option, ) -> impl Future> { let client = self.client.clone(); - let name = name.to_owned(); + let name = name.trim_start_matches("#").to_owned(); async move { Ok(client .request(proto::CreateChannel { name, parent_id }) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 9dc4ad805b9aed614f2d8c14f95f20c04e05d87c..c3ffc126342e531e08b39bb25ec5e6c5a591a41f 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1381,16 +1381,8 @@ impl Database { ) -> Result> { self.room_transaction(room_id, |tx| async move { if let Some(channel_id) = channel_id { - channel_member::Entity::find() - .filter( - channel_member::Column::ChannelId - .eq(channel_id) - .and(channel_member::Column::UserId.eq(user_id)) - .and(channel_member::Column::Accepted.eq(true)), - ) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("no such channel membership"))?; + self.check_user_is_channel_member(channel_id, user_id, &*tx) + .await?; room_participant::ActiveModel { room_id: ActiveValue::set(room_id), @@ -1738,7 +1730,6 @@ impl Database { } let (channel_id, room) = self.get_channel_room(room_id, &tx).await?; - let channel_members = if let Some(channel_id) = channel_id { self.get_channel_members_internal(channel_id, &tx).await? } else { @@ -3595,6 +3586,25 @@ impl Database { Ok(user_ids) } + async fn check_user_is_channel_member( + &self, + channel_id: ChannelId, + user_id: UserId, + tx: &DatabaseTransaction, + ) -> Result<()> { + let channel_ids = self.get_channel_ancestors(channel_id, tx).await?; + channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .is_in(channel_ids) + .and(channel_member::Column::UserId.eq(user_id)), + ) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("user is not a channel member"))?; + Ok(()) + } + async fn check_user_is_channel_admin( &self, channel_id: ChannelId, @@ -3611,7 +3621,7 @@ impl Database { ) .one(&*tx) .await? - .ok_or_else(|| anyhow!("user is not allowed to remove this channel"))?; + .ok_or_else(|| anyhow!("user is not a channel admin"))?; Ok(()) } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index ae149f6a8aa03e81e378e77bae84d53bc610aed2..88d88a40fd8f0f835363c9d42cb278ef6478439c 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -313,6 +313,38 @@ fn assert_members_eq( ); } +#[gpui::test] +async fn test_joining_channel_ancestor_member( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let parent_id = server + .make_channel("parent", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + let sub_id = client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.create_channel("sub_channel", Some(parent_id)) + }) + .await + .unwrap(); + + let active_call_b = cx_b.read(ActiveCall::global); + + assert!(active_call_b + .update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx)) + .await + .is_ok()); +} + #[gpui::test] async fn test_channel_room( deterministic: Arc, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index a84c5c111eb50082feb3d148cfe6ab27c6e9ded9..382381dba14bc64765cd29ece4ef5d5ab68ad1dd 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -120,7 +120,8 @@ pub enum Event { enum Section { ActiveCall, Channels, - Requests, + ChannelInvites, + ContactRequests, Contacts, Online, Offline, @@ -404,17 +405,55 @@ impl CollabPanel { let old_entries = mem::take(&mut self.entries); if let Some(room) = ActiveCall::global(cx).read(cx).room() { - let room = room.read(cx); - let mut participant_entries = Vec::new(); + self.entries.push(ListEntry::Header(Section::ActiveCall, 0)); - // Populate the active user. - if let Some(user) = user_store.current_user() { + if !self.collapsed_sections.contains(&Section::ActiveCall) { + let room = room.read(cx); + + // Populate the active user. + if let Some(user) = user_store.current_user() { + self.match_candidates.clear(); + self.match_candidates.push(StringMatchCandidate { + id: 0, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + let user_id = user.id; + self.entries.push(ListEntry::CallParticipant { + user, + is_pending: false, + }); + let mut projects = room.local_participant().projects.iter().peekable(); + while let Some(project) = projects.next() { + self.entries.push(ListEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: user_id, + is_last: projects.peek().is_none(), + }); + } + } + } + + // Populate remote participants. self.match_candidates.clear(); - self.match_candidates.push(StringMatchCandidate { - id: 0, - string: user.github_login.clone(), - char_bag: user.github_login.chars().collect(), - }); + self.match_candidates + .extend(room.remote_participants().iter().map(|(_, participant)| { + StringMatchCandidate { + id: participant.user.id as usize, + string: participant.user.github_login.clone(), + char_bag: participant.user.github_login.chars().collect(), + } + })); let matches = executor.block(match_strings( &self.match_candidates, &query, @@ -423,97 +462,54 @@ impl CollabPanel { &Default::default(), executor.clone(), )); - if !matches.is_empty() { - let user_id = user.id; - participant_entries.push(ListEntry::CallParticipant { - user, + for mat in matches { + let user_id = mat.candidate_id as u64; + let participant = &room.remote_participants()[&user_id]; + self.entries.push(ListEntry::CallParticipant { + user: participant.user.clone(), is_pending: false, }); - let mut projects = room.local_participant().projects.iter().peekable(); + let mut projects = participant.projects.iter().peekable(); while let Some(project) = projects.next() { - participant_entries.push(ListEntry::ParticipantProject { + self.entries.push(ListEntry::ParticipantProject { project_id: project.id, worktree_root_names: project.worktree_root_names.clone(), - host_user_id: user_id, - is_last: projects.peek().is_none(), + host_user_id: participant.user.id, + is_last: projects.peek().is_none() + && participant.video_tracks.is_empty(), }); } - } - } - - // Populate remote participants. - self.match_candidates.clear(); - self.match_candidates - .extend(room.remote_participants().iter().map(|(_, participant)| { - StringMatchCandidate { - id: participant.user.id as usize, - string: participant.user.github_login.clone(), - char_bag: participant.user.github_login.chars().collect(), + if !participant.video_tracks.is_empty() { + self.entries.push(ListEntry::ParticipantScreen { + peer_id: participant.peer_id, + is_last: true, + }); } - })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - for mat in matches { - let user_id = mat.candidate_id as u64; - let participant = &room.remote_participants()[&user_id]; - participant_entries.push(ListEntry::CallParticipant { - user: participant.user.clone(), - is_pending: false, - }); - let mut projects = participant.projects.iter().peekable(); - while let Some(project) = projects.next() { - participant_entries.push(ListEntry::ParticipantProject { - project_id: project.id, - worktree_root_names: project.worktree_root_names.clone(), - host_user_id: participant.user.id, - is_last: projects.peek().is_none() && participant.video_tracks.is_empty(), - }); - } - if !participant.video_tracks.is_empty() { - participant_entries.push(ListEntry::ParticipantScreen { - peer_id: participant.peer_id, - is_last: true, - }); } - } - // Populate pending participants. - self.match_candidates.clear(); - self.match_candidates - .extend( - room.pending_participants() - .iter() - .enumerate() - .map(|(id, participant)| StringMatchCandidate { + // Populate pending participants. + self.match_candidates.clear(); + self.match_candidates + .extend(room.pending_participants().iter().enumerate().map( + |(id, participant)| StringMatchCandidate { id, string: participant.github_login.clone(), char_bag: participant.github_login.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - participant_entries.extend(matches.iter().map(|mat| ListEntry::CallParticipant { - user: room.pending_participants()[mat.candidate_id].clone(), - is_pending: true, - })); - - if !participant_entries.is_empty() { - self.entries.push(ListEntry::Header(Section::ActiveCall, 0)); - if !self.collapsed_sections.contains(&Section::ActiveCall) { - self.entries.extend(participant_entries); - } + }, + )); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + self.entries + .extend(matches.iter().map(|mat| ListEntry::CallParticipant { + user: room.pending_participants()[mat.candidate_id].clone(), + is_pending: true, + })); } } @@ -559,8 +555,6 @@ impl CollabPanel { } } - self.entries.push(ListEntry::Header(Section::Contacts, 0)); - let mut request_entries = Vec::new(); let channel_invites = channel_store.channel_invitations(); if !channel_invites.is_empty() { @@ -586,8 +580,19 @@ impl CollabPanel { .iter() .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())), ); + + if !request_entries.is_empty() { + self.entries + .push(ListEntry::Header(Section::ChannelInvites, 1)); + if !self.collapsed_sections.contains(&Section::ChannelInvites) { + self.entries.append(&mut request_entries); + } + } } + self.entries.push(ListEntry::Header(Section::Contacts, 0)); + + request_entries.clear(); let incoming = user_store.incoming_contact_requests(); if !incoming.is_empty() { self.match_candidates.clear(); @@ -647,8 +652,9 @@ impl CollabPanel { } if !request_entries.is_empty() { - self.entries.push(ListEntry::Header(Section::Requests, 1)); - if !self.collapsed_sections.contains(&Section::Requests) { + self.entries + .push(ListEntry::Header(Section::ContactRequests, 1)); + if !self.collapsed_sections.contains(&Section::ContactRequests) { self.entries.append(&mut request_entries); } } @@ -1043,9 +1049,10 @@ impl CollabPanel { let tooltip_style = &theme.tooltip; let text = match section { Section::ActiveCall => "Current Call", - Section::Requests => "Requests", + Section::ContactRequests => "Requests", Section::Contacts => "Contacts", Section::Channels => "Channels", + Section::ChannelInvites => "Invites", Section::Online => "Online", Section::Offline => "Offline", }; @@ -1055,15 +1062,13 @@ impl CollabPanel { Section::ActiveCall => Some( MouseEventHandler::::new(0, cx, |_, _| { render_icon_button( - &theme.collab_panel.leave_call_button, + theme.collab_panel.leave_call_button.in_state(is_selected), "icons/radix/exit.svg", ) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, |_, _, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); + Self::leave_call(cx); }) .with_tooltip::( 0, @@ -1076,7 +1081,7 @@ impl CollabPanel { Section::Contacts => Some( MouseEventHandler::::new(0, cx, |_, _| { render_icon_button( - &theme.collab_panel.add_contact_button, + theme.collab_panel.add_contact_button.in_state(is_selected), "icons/user_plus_16.svg", ) }) @@ -1094,7 +1099,10 @@ impl CollabPanel { ), Section::Channels => Some( MouseEventHandler::::new(0, cx, |_, _| { - render_icon_button(&theme.collab_panel.add_contact_button, "icons/plus_16.svg") + render_icon_button( + theme.collab_panel.add_contact_button.in_state(is_selected), + "icons/plus_16.svg", + ) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx)) @@ -1284,10 +1292,10 @@ impl CollabPanel { MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() .with_child( - Svg::new("icons/channels.svg") - .with_color(theme.add_channel_button.color) + Svg::new("icons/channel_hash.svg") + .with_color(theme.channel_hash.color) .constrained() - .with_width(14.) + .with_width(theme.channel_hash.width) .aligned() .left(), ) @@ -1313,11 +1321,15 @@ impl CollabPanel { }), ), ) + .align_children_center() .constrained() .with_height(theme.row_height) .contained() .with_style(*theme.contact_row.in_state(is_selected).style_for(state)) - .with_margin_left(20. * channel.depth as f32) + .with_padding_left( + theme.contact_row.default_style().padding.left + + theme.channel_indent * channel.depth as f32, + ) }) .on_click(MouseButton::Left, move |_, this, cx| { this.join_channel(channel_id, cx); @@ -1345,7 +1357,14 @@ impl CollabPanel { let button_spacing = theme.contact_button_spacing; Flex::row() - .with_child(Svg::new("icons/file_icons/hash.svg").aligned().left()) + .with_child( + Svg::new("icons/channel_hash.svg") + .with_color(theme.channel_hash.color) + .constrained() + .with_width(theme.channel_hash.width) + .aligned() + .left(), + ) .with_child( Label::new(channel.name.clone(), theme.contact_username.text.clone()) .contained() @@ -1403,6 +1422,9 @@ impl CollabPanel { .in_state(is_selected) .style_for(&mut Default::default()), ) + .with_padding_left( + theme.contact_row.default_style().padding.left + theme.channel_indent, + ) .into_any() } @@ -1532,30 +1554,23 @@ impl CollabPanel { } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - let mut did_clear = self.filter_editor.update(cx, |editor, cx| { - if editor.buffer().read(cx).len(cx) > 0 { - editor.set_text("", cx); - true - } else { - false - } - }); - - did_clear |= self.take_editing_state(cx).is_some(); - - if !did_clear { - cx.emit(Event::Dismissed); + if self.take_editing_state(cx).is_some() { + cx.focus(&self.filter_editor); + } else { + self.filter_editor.update(cx, |editor, cx| { + if editor.buffer().read(cx).len(cx) > 0 { + editor.set_text("", cx); + } + }); } + + self.update_entries(cx); } fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let mut ix = self.selection.map_or(0, |ix| ix + 1); - while let Some(entry) = self.entries.get(ix) { - if entry.is_selectable() { - self.selection = Some(ix); - break; - } - ix += 1; + let ix = self.selection.map_or(0, |ix| ix + 1); + if ix < self.entries.len() { + self.selection = Some(ix); } self.list_state.reset(self.entries.len()); @@ -1569,16 +1584,9 @@ impl CollabPanel { } fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - if let Some(mut ix) = self.selection.take() { - while ix > 0 { - ix -= 1; - if let Some(entry) = self.entries.get(ix) { - if entry.is_selectable() { - self.selection = Some(ix); - break; - } - } - } + let ix = self.selection.take().unwrap_or(0); + if ix > 0 { + self.selection = Some(ix - 1); } self.list_state.reset(self.entries.len()); @@ -1595,9 +1603,17 @@ impl CollabPanel { if let Some(selection) = self.selection { if let Some(entry) = self.entries.get(selection) { match entry { - ListEntry::Header(section, _) => { - self.toggle_expanded(*section, cx); - } + ListEntry::Header(section, _) => match section { + Section::ActiveCall => Self::leave_call(cx), + Section::Channels => self.new_root_channel(cx), + Section::Contacts => self.toggle_contact_finder(cx), + Section::ContactRequests + | Section::Online + | Section::Offline + | Section::ChannelInvites => { + self.toggle_expanded(*section, cx); + } + }, ListEntry::Contact { contact, calling } => { if contact.online && !contact.busy && !calling { self.call(contact.user.id, Some(self.project.clone()), cx); @@ -1626,6 +1642,9 @@ impl CollabPanel { }); } } + ListEntry::Channel(channel) => { + self.join_channel(channel.id, cx); + } _ => {} } } @@ -1651,6 +1670,12 @@ impl CollabPanel { self.update_entries(cx); } + fn leave_call(cx: &mut ViewContext) { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + } + fn toggle_contact_finder(&mut self, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade(cx) { workspace.update(cx, |workspace, cx| { @@ -1666,23 +1691,17 @@ impl CollabPanel { } fn new_root_channel(&mut self, cx: &mut ViewContext) { - if self.channel_editing_state.is_none() { - self.channel_editing_state = Some(ChannelEditingState { parent_id: None }); - self.update_entries(cx); - } - + self.channel_editing_state = Some(ChannelEditingState { parent_id: None }); + self.update_entries(cx); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext) { - if self.channel_editing_state.is_none() { - self.channel_editing_state = Some(ChannelEditingState { - parent_id: Some(action.channel_id), - }); - self.update_entries(cx); - } - + self.channel_editing_state = Some(ChannelEditingState { + parent_id: Some(action.channel_id), + }); + self.update_entries(cx); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } @@ -1825,6 +1844,13 @@ impl View for CollabPanel { fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { if !self.has_focus { self.has_focus = true; + if !self.context_menu.is_focused(cx) { + if self.channel_editing_state.is_some() { + cx.focus(&self.channel_name_editor); + } else { + cx.focus(&self.filter_editor); + } + } cx.emit(Event::Focus); } } @@ -1931,16 +1957,6 @@ impl Panel for CollabPanel { } } -impl ListEntry { - fn is_selectable(&self) -> bool { - if let ListEntry::Header(_, 0) = self { - false - } else { - true - } - } -} - impl PartialEq for ListEntry { fn eq(&self, other: &Self) -> bool { match self { diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 0286e30b808a0c54306ca6c4776f21434557b553..1b1a50dbe48b61482b699e5557e7291da835d7d8 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -487,7 +487,7 @@ impl ChannelModalDelegate { }); cx.spawn(|picker, mut cx| async move { update.await?; - picker.update(&mut cx, |picker, cx| { + picker.update(&mut cx, |picker, _| { let this = picker.delegate_mut(); if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user_id) { member.admin = admin; @@ -503,7 +503,7 @@ impl ChannelModalDelegate { }); cx.spawn(|picker, mut cx| async move { update.await?; - picker.update(&mut cx, |picker, cx| { + picker.update(&mut cx, |picker, _| { let this = picker.delegate_mut(); if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) { this.members.remove(ix); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 448f6ca5dda861a71f09a077c5883c71efb20262..8bd673d1b3258b4b667026d263498209ad00251a 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,12 +220,13 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, + pub channel_hash: Icon, pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, - pub leave_call_button: IconButton, - pub add_contact_button: IconButton, - pub add_channel_button: IconButton, + pub leave_call_button: Toggleable, + pub add_contact_button: Toggleable, + pub add_channel_button: Toggleable, pub header_row: ContainedText, pub subheader_row: Toggleable>, pub leave_call: Interactive, @@ -239,6 +240,7 @@ pub struct CollabPanel { pub contact_username: ContainedText, pub contact_button: Interactive, pub contact_button_spacing: f32, + pub channel_indent: f32, pub disabled_button: IconButton, pub section_icon_size: f32, pub calling_indicator: ContainedText, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index ea550dea6b45f90c82a38ee1bf9202a15ef03369..f24468dca61715442fb5aedd9ceee3e075dcd374 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -51,6 +51,20 @@ export default function contacts_panel(): any { }, } + const headerButton = toggleable({ + base: { + color: foreground(layer, "on"), + button_width: 28, + icon_width: 16, + }, + state: { + active: { + background: background(layer, "active"), + corner_radius: 8, + } + } + }) + return { channel_modal: channel_modal(), background: background(layer), @@ -77,23 +91,16 @@ export default function contacts_panel(): any { right: side_padding, }, }, - user_query_editor_height: 33, - add_contact_button: { - color: foreground(layer, "on"), - button_width: 28, - icon_width: 16, - }, - add_channel_button: { - color: foreground(layer, "on"), - button_width: 28, - icon_width: 16, - }, - leave_call_button: { + channel_hash: { color: foreground(layer, "on"), - button_width: 28, - icon_width: 16, + width: 14, }, + user_query_editor_height: 33, + add_contact_button: headerButton, + add_channel_button: headerButton, + leave_call_button: headerButton, row_height: 28, + channel_indent: 10, section_icon_size: 8, header_row: { ...text(layer, "mono", { size: "sm", weight: "bold" }), From f1957b1737648429d2272f001abc995067027dce Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 13:31:09 -0700 Subject: [PATCH 044/105] Push focus and fix keybindings --- assets/keymaps/default.json | 8 +++- .../src/collab_panel/channel_modal.rs | 45 ++++++++++--------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index d99a6608504aec59617a4d644088a1e54d0db31f..11cc50a03e3f17757083e41e8edc9cc3436e6cb3 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -553,8 +553,12 @@ { "context": "ChannelModal", "bindings": { - "left": "channel_modal::SelectNextControl", - "right": "channel_modal::SelectNextControl", + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "ChannelModal > Picker > Editor", + "bindings": { "tab": "channel_modal::ToggleMode" } }, diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 1b1a50dbe48b61482b699e5557e7291da835d7d8..f1775eb084e51475bc284fd14abb0dbf3e287f66 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -16,7 +16,7 @@ actions!(channel_modal, [SelectNextControl, ToggleMode]); pub fn init(cx: &mut AppContext) { Picker::::init(cx); cx.add_action(ChannelModal::toggle_mode); - cx.add_action(ChannelModal::select_next_control); + // cx.add_action(ChannelModal::select_next_control); } pub struct ChannelModal { @@ -64,6 +64,7 @@ impl ChannelModal { }); cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); + let has_focus = picker.read(cx).has_focus(); Self { @@ -105,29 +106,30 @@ impl ChannelModal { picker.update_matches(picker.query(cx), cx); cx.notify() }); + cx.notify() }) }) .detach(); } - fn select_next_control(&mut self, _: &SelectNextControl, cx: &mut ViewContext) { - self.picker.update(cx, |picker, cx| { - let delegate = picker.delegate_mut(); - match delegate.mode { - Mode::ManageMembers => match delegate.selected_column { - Some(UserColumn::Remove) => { - delegate.selected_column = Some(UserColumn::ToggleAdmin) - } - Some(UserColumn::ToggleAdmin) => { - delegate.selected_column = Some(UserColumn::Remove) - } - None => todo!(), - }, - Mode::InviteMembers => {} - } - cx.notify() - }); - } + // fn select_next_control(&mut self, _: &SelectNextControl, cx: &mut ViewContext) { + // self.picker.update(cx, |picker, cx| { + // let delegate = picker.delegate_mut(); + // match delegate.mode { + // Mode::ManageMembers => match delegate.selected_column { + // Some(UserColumn::Remove) => { + // delegate.selected_column = Some(UserColumn::ToggleAdmin) + // } + // Some(UserColumn::ToggleAdmin) => { + // delegate.selected_column = Some(UserColumn::Remove) + // } + // None => todo!(), + // }, + // Mode::InviteMembers => {} + // } + // cx.notify() + // }); + // } } impl Entity for ChannelModal { @@ -209,8 +211,11 @@ impl View for ChannelModal { .into_any() } - fn focus_in(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { self.has_focus = true; + if cx.is_self_focused() { + cx.focus(&self.picker) + } } fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { From 90cdbe8bf37c3683856ca17efa82a4308e0bec28 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 13:39:05 -0700 Subject: [PATCH 045/105] Fix modal click throughs and adjust height for channel modal --- .../src/collab_panel/channel_modal.rs | 2 +- crates/workspace/src/workspace.rs | 19 ++++++++++++++----- styles/src/style_tree/channel_modal.ts | 3 ++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index f1775eb084e51475bc284fd14abb0dbf3e287f66..0671eee8af78d39b05f906cd13a32c0b216d421c 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -205,7 +205,7 @@ impl View for ChannelModal { ])) .with_child(ChildView::new(&self.picker, cx)) .constrained() - .with_height(theme.height) + .with_max_height(theme.height) .contained() .with_style(theme.container) .into_any() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index cec9904eaca34471a4f2af52d58c512a1a9d43e0..e01f81c29e40395eb3f8af42d5aeff332885f0a6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3755,11 +3755,20 @@ impl View for Workspace { ) })) .with_children(self.modal.as_ref().map(|modal| { - ChildView::new(modal.view.as_any(), cx) - .contained() - .with_style(theme.workspace.modal) - .aligned() - .top() + enum ModalBackground {} + MouseEventHandler::::new( + 0, + cx, + |_, cx| { + ChildView::new(modal.view.as_any(), cx) + .contained() + .with_style(theme.workspace.modal) + .aligned() + .top() + }, + ) + .on_click(MouseButton::Left, |_, _, _| {}) + // Consume click events to stop focus dropping through })) .with_children(self.render_notifications(&theme.workspace, cx)), )) diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index a097bc561f75afbfe1607e976bbb2a914a1e3b6f..8dc9e799677894355b815c6af9f046fb83ac9a82 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -29,7 +29,7 @@ export default function channel_modal(): any { selection: theme.players[0], border: border(theme.middle), padding: { - bottom: 4, + bottom: 8, left: 8, right: 8, top: 4, @@ -37,6 +37,7 @@ export default function channel_modal(): any { margin: { left: side_margin, right: side_margin, + bottom: 8, }, } From 9913067e51933287ca0c74bce33d916fdf3b5113 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 14:32:09 -0700 Subject: [PATCH 046/105] Remove admin and member button Fix bug with invites in the member list Fix bug when there are network errors in the member related RPC calls co-authored-by: Max --- crates/client/src/channel_store.rs | 21 +++- .../src/collab_panel/channel_modal.rs | 119 +++++++++--------- crates/theme/src/theme.rs | 3 +- styles/src/style_tree/channel_modal.ts | 15 +-- 4 files changed, 76 insertions(+), 82 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 13510a1e1c899f4355e36e7ccd403fa35fb3eaa4..317fbd118973ef93d8ef1cf69e306d061f3cad38 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -127,17 +127,21 @@ impl ChannelStore { cx.notify(); let client = self.client.clone(); cx.spawn(|this, mut cx| async move { - client + let result = client .request(proto::InviteChannelMember { channel_id, user_id, admin, }) - .await?; + .await; + this.update(&mut cx, |this, cx| { this.outgoing_invites.remove(&(channel_id, user_id)); cx.notify(); }); + + result?; + Ok(()) }) } @@ -155,16 +159,18 @@ impl ChannelStore { cx.notify(); let client = self.client.clone(); cx.spawn(|this, mut cx| async move { - client + let result = client .request(proto::RemoveChannelMember { channel_id, user_id, }) - .await?; + .await; + this.update(&mut cx, |this, cx| { this.outgoing_invites.remove(&(channel_id, user_id)); cx.notify(); }); + result?; Ok(()) }) } @@ -183,17 +189,20 @@ impl ChannelStore { cx.notify(); let client = self.client.clone(); cx.spawn(|this, mut cx| async move { - client + let result = client .request(proto::SetChannelMemberAdmin { channel_id, user_id, admin, }) - .await?; + .await; + this.update(&mut cx, |this, cx| { this.outgoing_invites.remove(&(channel_id, user_id)); cx.notify(); }); + + result?; Ok(()) }) } diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 0671eee8af78d39b05f906cd13a32c0b216d421c..fc1b86354fa183b1360b6be78f549f2fe99b958b 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -45,15 +45,7 @@ impl ChannelModal { user_store: user_store.clone(), channel_store: channel_store.clone(), channel_id, - match_candidates: members - .iter() - .enumerate() - .map(|(id, member)| StringMatchCandidate { - id, - string: member.user.github_login.clone(), - char_bag: member.user.github_login.chars().collect(), - }) - .collect(), + match_candidates: Vec::new(), members, mode, selected_column: None, @@ -256,7 +248,7 @@ pub struct ChannelModalDelegate { selected_index: usize, mode: Mode, selected_column: Option, - match_candidates: Arc<[StringMatchCandidate]>, + match_candidates: Vec, members: Vec, } @@ -287,30 +279,36 @@ impl PickerDelegate for ChannelModalDelegate { fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { match self.mode { Mode::ManageMembers => { - let match_candidates = self.match_candidates.clone(); + 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().block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + cx.background().clone(), + )); + cx.spawn(|picker, mut cx| async move { - async move { - let matches = match_strings( - &match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - cx.background().clone(), - ) - .await; - picker.update(&mut cx, |picker, cx| { + picker + .update(&mut cx, |picker, cx| { let delegate = picker.delegate_mut(); delegate.matching_member_indices.clear(); delegate .matching_member_indices .extend(matches.into_iter().map(|m| m.candidate_id)); cx.notify(); - })?; - anyhow::Ok(()) - } - .log_err() - .await; + }) + .ok(); }) } Mode::InviteMembers => { @@ -346,11 +344,7 @@ impl PickerDelegate for ChannelModalDelegate { } } Some(proto::channel_member::Kind::AncestorMember) | None => { - self.channel_store - .update(cx, |store, cx| { - store.invite_member(self.channel_id, selected_user.id, false, cx) - }) - .detach(); + self.invite_member(selected_user, cx) } } } @@ -386,41 +380,24 @@ impl PickerDelegate for ChannelModalDelegate { .aligned() .left(), ) - .with_children(admin.map(|admin| { - let member_style = theme.admin_toggle_part.in_state(!admin); - let admin_style = theme.admin_toggle_part.in_state(admin); - Flex::row() - .with_child( - Label::new("member", member_style.text.clone()) - .contained() - .with_style(member_style.container), - ) - .with_child( - Label::new("admin", admin_style.text.clone()) - .contained() - .with_style(admin_style.container), - ) + .with_children(admin.map(|_| { + Label::new("admin", theme.admin_toggle.text.clone()) .contained() - .with_style(theme.admin_toggle) + .with_style(theme.admin_toggle.container) .aligned() - .flex_float() })) .with_children({ match self.mode { Mode::ManageMembers => match request_status { - Some(proto::channel_member::Kind::Member) => Some( - Label::new("remove member", theme.remove_member_button.text.clone()) - .contained() - .with_style(theme.remove_member_button.container) - .into_any(), - ), Some(proto::channel_member::Kind::Invitee) => Some( Label::new("cancel invite", theme.cancel_invite_button.text.clone()) .contained() .with_style(theme.cancel_invite_button.container) .into_any(), ), - Some(proto::channel_member::Kind::AncestorMember) | None => None, + Some(proto::channel_member::Kind::Member) + | Some(proto::channel_member::Kind::AncestorMember) + | None => None, }, Mode::InviteMembers => { let svg = match request_status { @@ -466,11 +443,12 @@ impl ChannelModalDelegate { self.members .iter() .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind)) - .or(self - .channel_store - .read(cx) - .has_pending_channel_invite(self.channel_id, user_id) - .then_some(proto::channel_member::Kind::Invitee)) + .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, Option)> { @@ -517,4 +495,25 @@ impl ChannelModalDelegate { }) .detach_and_log_err(cx); } + + fn invite_member(&mut self, user: Arc, cx: &mut ViewContext>) { + let invite_member = self.channel_store.update(cx, |store, cx| { + store.invite_member(self.channel_id, user.id, false, cx) + }); + + cx.spawn(|this, mut cx| async move { + invite_member.await?; + + this.update(&mut cx, |this, cx| { + let delegate_mut = this.delegate_mut(); + delegate_mut.members.push(ChannelMembership { + user, + kind: proto::channel_member::Kind::Invitee, + admin: false, + }); + cx.notify(); + }) + }) + .detach_and_log_err(cx); + } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 8bd673d1b3258b4b667026d263498209ad00251a..c778b5fc88936d8357f03b060dc58c009db05bb2 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -261,8 +261,7 @@ pub struct ChannelModal { pub cancel_invite_button: ContainedText, pub member_icon: Icon, pub invitee_icon: Icon, - pub admin_toggle: ContainerStyle, - pub admin_toggle_part: Toggleable, + pub admin_toggle: ContainedText, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 8dc9e799677894355b815c6af9f046fb83ac9a82..40fd497458ecd4cd6b9ace0a7d0f6744ad445e15 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -76,21 +76,8 @@ export default function channel_modal(): any { ...text(theme.middle, "sans", { size: "xs" }), background: background(theme.middle), }, - admin_toggle_part: toggleable({ - base: { - ...text(theme.middle, "sans", { size: "xs" }), - padding: { - left: 7, - right: 7, - }, - }, - state: { - active: { - background: background(theme.middle, "on"), - } - } - }), admin_toggle: { + ...text(theme.middle, "sans", { size: "xs" }), border: border(theme.middle, "active"), background: background(theme.middle), margin: { From e37e76fc0bdb1f690162d6f055d48c4585f7f9b5 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 15:29:30 -0700 Subject: [PATCH 047/105] Add context menu controls to the channel member management co-authored-by: Max --- .../src/collab_panel/channel_modal.rs | 244 +++++++++++------- crates/theme/src/theme.rs | 2 +- styles/src/style_tree/channel_modal.ts | 8 +- 3 files changed, 161 insertions(+), 93 deletions(-) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index fc1b86354fa183b1360b6be78f549f2fe99b958b..8747d9a0afea44395fb72af7d5e67f5b9fb6c67f 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -1,4 +1,5 @@ use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore}; +use context_menu::{ContextMenu, ContextMenuItem}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, @@ -11,12 +12,21 @@ use std::sync::Arc; use util::TryFutureExt; use workspace::Modal; -actions!(channel_modal, [SelectNextControl, ToggleMode]); +actions!( + channel_modal, + [ + SelectNextControl, + ToggleMode, + ToggleMemberAdmin, + RemoveMember + ] +); pub fn init(cx: &mut AppContext) { Picker::::init(cx); cx.add_action(ChannelModal::toggle_mode); - // cx.add_action(ChannelModal::select_next_control); + cx.add_action(ChannelModal::toggle_member_admin); + cx.add_action(ChannelModal::remove_member); } pub struct ChannelModal { @@ -48,7 +58,11 @@ impl ChannelModal { match_candidates: Vec::new(), members, mode, - selected_column: None, + context_menu: cx.add_view(|cx| { + let mut menu = ContextMenu::new(cx.view_id(), cx); + menu.set_position_mode(OverlayPositionMode::Local); + menu + }), }, cx, ) @@ -95,6 +109,8 @@ impl ChannelModal { this.picker.update(cx, |picker, cx| { let delegate = picker.delegate_mut(); delegate.mode = mode; + delegate.selected_index = 0; + picker.set_query("", cx); picker.update_matches(picker.query(cx), cx); cx.notify() }); @@ -104,24 +120,17 @@ impl ChannelModal { .detach(); } - // fn select_next_control(&mut self, _: &SelectNextControl, cx: &mut ViewContext) { - // self.picker.update(cx, |picker, cx| { - // let delegate = picker.delegate_mut(); - // match delegate.mode { - // Mode::ManageMembers => match delegate.selected_column { - // Some(UserColumn::Remove) => { - // delegate.selected_column = Some(UserColumn::ToggleAdmin) - // } - // Some(UserColumn::ToggleAdmin) => { - // delegate.selected_column = Some(UserColumn::Remove) - // } - // None => todo!(), - // }, - // Mode::InviteMembers => {} - // } - // cx.notify() - // }); - // } + fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| { + picker.delegate_mut().toggle_selected_member_admin(cx); + }) + } + + fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| { + picker.delegate_mut().remove_selected_member(cx); + }); + } } impl Entity for ChannelModal { @@ -233,12 +242,6 @@ pub enum Mode { InviteMembers, } -#[derive(Copy, Clone, PartialEq)] -pub enum UserColumn { - ToggleAdmin, - Remove, -} - pub struct ChannelModalDelegate { matching_users: Vec>, matching_member_indices: Vec, @@ -247,9 +250,9 @@ pub struct ChannelModalDelegate { channel_id: ChannelId, selected_index: usize, mode: Mode, - selected_column: Option, match_candidates: Vec, members: Vec, + context_menu: ViewHandle, } impl PickerDelegate for ChannelModalDelegate { @@ -270,10 +273,6 @@ impl PickerDelegate for ChannelModalDelegate { fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { self.selected_index = ix; - self.selected_column = match self.mode { - Mode::ManageMembers => Some(UserColumn::ToggleAdmin), - Mode::InviteMembers => None, - }; } fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { @@ -334,18 +333,17 @@ impl PickerDelegate for ChannelModalDelegate { fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) { - match self.member_status(selected_user.id, cx) { - Some(proto::channel_member::Kind::Member) - | Some(proto::channel_member::Kind::Invitee) => { - if self.selected_column == Some(UserColumn::ToggleAdmin) { - self.set_member_admin(selected_user.id, !admin.unwrap_or(false), cx); - } else { + match self.mode { + Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), 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::AncestorMember) | None => { + self.invite_member(selected_user, cx) + } + Some(proto::channel_member::Kind::Member) => {} + }, } } } @@ -366,7 +364,10 @@ impl PickerDelegate for ChannelModalDelegate { let request_status = self.member_status(user.id, cx); let style = theme.picker.item.in_state(selected).style_for(mouse_state); - Flex::row() + + let in_manage = matches!(self.mode, Mode::ManageMembers); + + let mut result = Flex::row() .with_children(user.avatar.clone().map(|avatar| { Image::from_data(avatar) .with_style(theme.contact_avatar) @@ -380,57 +381,81 @@ impl PickerDelegate for ChannelModalDelegate { .aligned() .left(), ) - .with_children(admin.map(|_| { - Label::new("admin", theme.admin_toggle.text.clone()) - .contained() - .with_style(theme.admin_toggle.container) - .aligned() + .with_children({ + (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then( + || { + Label::new("Invited", theme.member_tag.text.clone()) + .contained() + .with_style(theme.member_tag.container) + .aligned() + .left() + }, + ) + }) + .with_children(admin.and_then(|admin| { + (in_manage && admin).then(|| { + Label::new("Admin", theme.member_tag.text.clone()) + .contained() + .with_style(theme.member_tag.container) + .aligned() + .left() + }) })) .with_children({ - match self.mode { - Mode::ManageMembers => match request_status { + let svg = match self.mode { + Mode::ManageMembers => Some( + Svg::new("icons/ellipsis_14.svg") + .with_color(theme.member_icon.color) + .constrained() + .with_width(theme.member_icon.width) + .aligned() + .contained() + .with_style(theme.member_icon.container), + ), + Mode::InviteMembers => match request_status { + Some(proto::channel_member::Kind::Member) => Some( + Svg::new("icons/check_8.svg") + .with_color(theme.member_icon.color) + .constrained() + .with_width(theme.member_icon.width) + .aligned() + .contained() + .with_style(theme.member_icon.container), + ), Some(proto::channel_member::Kind::Invitee) => Some( - Label::new("cancel invite", theme.cancel_invite_button.text.clone()) + Svg::new("icons/check_8.svg") + .with_color(theme.invitee_icon.color) + .constrained() + .with_width(theme.invitee_icon.width) + .aligned() .contained() - .with_style(theme.cancel_invite_button.container) - .into_any(), + .with_style(theme.invitee_icon.container), ), - Some(proto::channel_member::Kind::Member) - | Some(proto::channel_member::Kind::AncestorMember) - | None => None, + Some(proto::channel_member::Kind::AncestorMember) | None => None, }, - Mode::InviteMembers => { - let svg = match request_status { - Some(proto::channel_member::Kind::Member) => Some( - Svg::new("icons/check_8.svg") - .with_color(theme.member_icon.color) - .constrained() - .with_width(theme.member_icon.width) - .aligned() - .contained() - .with_style(theme.member_icon.container), - ), - Some(proto::channel_member::Kind::Invitee) => Some( - Svg::new("icons/check_8.svg") - .with_color(theme.invitee_icon.color) - .constrained() - .with_width(theme.invitee_icon.width) - .aligned() - .contained() - .with_style(theme.invitee_icon.container), - ), - Some(proto::channel_member::Kind::AncestorMember) | None => None, - }; - - svg.map(|svg| svg.aligned().flex_float().into_any()) - } - } + }; + + svg.map(|svg| svg.aligned().flex_float().into_any()) }) .contained() .with_style(style.container) .constrained() .with_height(theme.row_height) - .into_any() + .into_any(); + + if selected { + result = Stack::new() + .with_child(result) + .with_child( + ChildView::new(&self.context_menu, cx) + .aligned() + .top() + .right(), + ) + .into_any(); + } + + result } } @@ -464,20 +489,30 @@ impl ChannelModalDelegate { } } - fn set_member_admin(&mut self, user_id: u64, admin: bool, cx: &mut ViewContext>) { + fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext>) -> Option<()> { + let (user, admin) = self.user_at_index(self.selected_index)?; + let admin = !admin.unwrap_or(false); let update = self.channel_store.update(cx, |store, cx| { - store.set_member_admin(self.channel_id, user_id, admin, cx) + store.set_member_admin(self.channel_id, user.id, admin, cx) }); cx.spawn(|picker, mut cx| async move { update.await?; - picker.update(&mut cx, |picker, _| { + picker.update(&mut cx, |picker, cx| { let this = picker.delegate_mut(); - 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.admin = admin; } + cx.notify(); }) }) .detach_and_log_err(cx); + Some(()) + } + + fn remove_selected_member(&mut self, cx: &mut ViewContext>) -> Option<()> { + let (user, _) = self.user_at_index(self.selected_index)?; + self.remove_member(user.id, cx); + Some(()) } fn remove_member(&mut self, user_id: u64, cx: &mut ViewContext>) { @@ -486,11 +521,20 @@ impl ChannelModalDelegate { }); cx.spawn(|picker, mut cx| async move { update.await?; - picker.update(&mut cx, |picker, _| { + picker.update(&mut cx, |picker, cx| { let this = picker.delegate_mut(); 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 + }) } + cx.notify(); }) }) .detach_and_log_err(cx); @@ -505,8 +549,7 @@ impl ChannelModalDelegate { invite_member.await?; this.update(&mut cx, |this, cx| { - let delegate_mut = this.delegate_mut(); - delegate_mut.members.push(ChannelMembership { + this.delegate_mut().members.push(ChannelMembership { user, kind: proto::channel_member::Kind::Invitee, admin: false, @@ -516,4 +559,25 @@ impl ChannelModalDelegate { }) .detach_and_log_err(cx); } + + fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext>) { + self.context_menu.update(cx, |context_menu, cx| { + context_menu.show( + Default::default(), + AnchorCorner::TopRight, + vec![ + ContextMenuItem::action("Remove", RemoveMember), + ContextMenuItem::action( + if user_is_admin { + "Make non-admin" + } else { + "Make admin" + }, + ToggleMemberAdmin, + ), + ], + cx, + ) + }) + } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index c778b5fc88936d8357f03b060dc58c009db05bb2..b3640f538fa3db7531c468b50bb276a425679d98 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -261,7 +261,7 @@ pub struct ChannelModal { pub cancel_invite_button: ContainedText, pub member_icon: Icon, pub invitee_icon: Icon, - pub admin_toggle: ContainedText, + pub member_tag: ContainedText, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 40fd497458ecd4cd6b9ace0a7d0f6744ad445e15..447522070bac2206f071035cc3cadc160d2dbd2a 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -76,12 +76,16 @@ export default function channel_modal(): any { ...text(theme.middle, "sans", { size: "xs" }), background: background(theme.middle), }, - admin_toggle: { + member_tag: { ...text(theme.middle, "sans", { size: "xs" }), border: border(theme.middle, "active"), background: background(theme.middle), margin: { - right: 8, + left: 8, + }, + padding: { + left: 4, + right: 4, } }, container: { From 8980a9f1c1b33a8661fb8c48da3e6a8418176c0d Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 16:27:47 -0700 Subject: [PATCH 048/105] Add settings for removing the assistant and collaboration panel buttons Add a not-logged-in state to the collaboration panel co-authored-by: max --- assets/settings/default.json | 10 +- crates/ai/src/assistant.rs | 7 +- crates/ai/src/assistant_settings.rs | 2 + crates/collab_ui/src/collab_panel.rs | 54 ++++-- .../src/collab_panel/panel_settings.rs | 18 +- crates/project_panel/src/project_panel.rs | 4 +- crates/terminal_view/src/terminal_panel.rs | 4 +- crates/theme/src/theme.rs | 1 + crates/workspace/src/dock.rs | 181 +++++++++--------- styles/src/style_tree/collab_panel.ts | 31 +++ 10 files changed, 196 insertions(+), 116 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index c40ed4e8da2dc99ccdf7f681f7ee69fe0829b405..08faedbed6bd1e872f26ef6a0112f6afee0a5fd2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -122,13 +122,17 @@ // Amount of indentation for nested items. "indent_size": 20 }, - "channels_panel": { + "collaboration_panel": { + // Whether to show the collaboration panel button in the status bar. + "button": true, // Where to dock channels panel. Can be 'left' or 'right'. "dock": "left", // Default width of the channels panel. "default_width": 240 }, "assistant": { + // Whether to show the assistant panel button in the status bar. + "button": true, // Where to dock the assistant. Can be 'left', 'right' or 'bottom'. "dock": "right", // Default width when the assistant is docked to the left or right. @@ -220,7 +224,9 @@ "copilot": { // The set of glob patterns for which copilot should be disabled // in any matching file. - "disabled_globs": [".env"] + "disabled_globs": [ + ".env" + ] }, // Settings specific to journaling "journal": { diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 957c5e1c063aad3de12657448267d0f813f38887..35d3c9f7ef9dcf76f22f5a2e26dccda23e1e12a7 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -192,6 +192,7 @@ impl AssistantPanel { old_dock_position = new_dock_position; cx.emit(AssistantPanelEvent::DockPositionChanged); } + cx.notify(); })]; this @@ -790,8 +791,10 @@ impl Panel for AssistantPanel { } } - fn icon_path(&self) -> &'static str { - "icons/robot_14.svg" + fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> { + settings::get::(cx) + .button + .then(|| "icons/robot_14.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/ai/src/assistant_settings.rs b/crates/ai/src/assistant_settings.rs index eb92e0f6e8c0bdd6e554844f2565057ed92e9ebd..04ba8fb946eb7f450fe95bc7565bb304f8b4a1d7 100644 --- a/crates/ai/src/assistant_settings.rs +++ b/crates/ai/src/assistant_settings.rs @@ -13,6 +13,7 @@ pub enum AssistantDockPosition { #[derive(Deserialize, Debug)] pub struct AssistantSettings { + pub button: bool, pub dock: AssistantDockPosition, pub default_width: f32, pub default_height: f32, @@ -20,6 +21,7 @@ pub struct AssistantSettings { #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] pub struct AssistantSettingsContent { + pub button: Option, pub dock: Option, pub default_width: Option, pub default_height: Option, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 382381dba14bc64765cd29ece4ef5d5ab68ad1dd..d8e26823164ea90216aefa6ac382c976fcca6047 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -27,7 +27,7 @@ use gpui::{ Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; use menu::{Confirm, SelectNext, SelectPrev}; -use panel_settings::{ChannelsPanelDockPosition, ChannelsPanelSettings}; +use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings}; use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; @@ -65,7 +65,7 @@ impl_actions!(collab_panel, [RemoveChannel, NewChannel, AddMember]); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; pub fn init(_client: Arc, cx: &mut AppContext) { - settings::register::(cx); + settings::register::(cx); contact_finder::init(cx); channel_modal::init(cx); @@ -95,6 +95,7 @@ pub struct CollabPanel { entries: Vec, selection: Option, user_store: ModelHandle, + client: Arc, channel_store: ModelHandle, project: ModelHandle, match_candidates: Vec, @@ -320,6 +321,7 @@ impl CollabPanel { match_candidates: Vec::default(), collapsed_sections: Vec::default(), workspace: workspace.weak_handle(), + client: workspace.app_state().client.clone(), list_state, }; this.update_entries(cx); @@ -334,6 +336,7 @@ impl CollabPanel { old_dock_position = new_dock_position; cx.emit(Event::DockPositionChanged); } + cx.notify(); }), ); @@ -1862,6 +1865,31 @@ impl View for CollabPanel { fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { let theme = &theme::current(cx).collab_panel; + if self.user_store.read(cx).current_user().is_none() { + enum LogInButton {} + + return Flex::column() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + let button = theme.log_in_button.style_for(state); + Label::new("Sign in to collaborate", button.text.clone()) + .contained() + .with_style(button.container) + }) + .on_click(MouseButton::Left, |_, this, cx| { + let client = this.client.clone(); + cx.spawn(|_, cx| async move { + client.authenticate_and_connect(true, &cx).await.log_err() + }) + .detach(); + }) + .with_cursor_style(CursorStyle::PointingHand), + ) + .contained() + .with_style(theme.container) + .into_any(); + } + enum PanelFocus {} MouseEventHandler::::new(0, cx, |_, cx| { Stack::new() @@ -1901,9 +1929,9 @@ impl View for CollabPanel { impl Panel for CollabPanel { fn position(&self, cx: &gpui::WindowContext) -> DockPosition { - match settings::get::(cx).dock { - ChannelsPanelDockPosition::Left => DockPosition::Left, - ChannelsPanelDockPosition::Right => DockPosition::Right, + match settings::get::(cx).dock { + CollaborationPanelDockPosition::Left => DockPosition::Left, + CollaborationPanelDockPosition::Right => DockPosition::Right, } } @@ -1912,13 +1940,15 @@ impl Panel for CollabPanel { } fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { - settings::update_settings_file::( + settings::update_settings_file::( self.fs.clone(), cx, move |settings| { let dock = match position { - DockPosition::Left | DockPosition::Bottom => ChannelsPanelDockPosition::Left, - DockPosition::Right => ChannelsPanelDockPosition::Right, + DockPosition::Left | DockPosition::Bottom => { + CollaborationPanelDockPosition::Left + } + DockPosition::Right => CollaborationPanelDockPosition::Right, }; settings.dock = Some(dock); }, @@ -1927,7 +1957,7 @@ impl Panel for CollabPanel { fn size(&self, cx: &gpui::WindowContext) -> f32 { self.width - .unwrap_or_else(|| settings::get::(cx).default_width) + .unwrap_or_else(|| settings::get::(cx).default_width) } fn set_size(&mut self, size: f32, cx: &mut ViewContext) { @@ -1936,8 +1966,10 @@ impl Panel for CollabPanel { cx.notify(); } - fn icon_path(&self) -> &'static str { - "icons/radix/person.svg" + fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { + settings::get::(cx) + .button + .then(|| "icons/radix/person.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/collab_ui/src/collab_panel/panel_settings.rs b/crates/collab_ui/src/collab_panel/panel_settings.rs index fe3484b782914dae3a42d50b39cd57b2477173ab..5e2954b9156067634c2225a8b80387720e0ba538 100644 --- a/crates/collab_ui/src/collab_panel/panel_settings.rs +++ b/crates/collab_ui/src/collab_panel/panel_settings.rs @@ -5,27 +5,29 @@ use settings::Setting; #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum ChannelsPanelDockPosition { +pub enum CollaborationPanelDockPosition { Left, Right, } #[derive(Deserialize, Debug)] -pub struct ChannelsPanelSettings { - pub dock: ChannelsPanelDockPosition, +pub struct CollaborationPanelSettings { + pub button: bool, + pub dock: CollaborationPanelDockPosition, pub default_width: f32, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] -pub struct ChannelsPanelSettingsContent { - pub dock: Option, +pub struct CollaborationPanelSettingsContent { + pub button: Option, + pub dock: Option, pub default_width: Option, } -impl Setting for ChannelsPanelSettings { - const KEY: Option<&'static str> = Some("channels_panel"); +impl Setting for CollaborationPanelSettings { + const KEY: Option<&'static str> = Some("collaboration_panel"); - type FileContent = ChannelsPanelSettingsContent; + type FileContent = CollaborationPanelSettingsContent; fn load( default_value: &Self::FileContent, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 0383117de8c2e8683c697398fff3d95bbe6ce66d..4d84a1c63866dffd4fa285bc145349bd6fffa35f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1657,8 +1657,8 @@ impl workspace::dock::Panel for ProjectPanel { cx.notify(); } - fn icon_path(&self) -> &'static str { - "icons/folder_tree_16.svg" + fn icon_path(&self, _: &WindowContext) -> Option<&'static str> { + Some("icons/folder_tree_16.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 6ad321c735deb00bb557058db5082761da9f7bbb..34752ad3c4f2f7bf9f91c95d3bf5532f5bd6fdf7 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -396,8 +396,8 @@ impl Panel for TerminalPanel { } } - fn icon_path(&self) -> &'static str { - "icons/terminal_12.svg" + fn icon_path(&self, _: &WindowContext) -> Option<&'static str> { + Some("icons/terminal_12.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index b3640f538fa3db7531c468b50bb276a425679d98..c554f77fe42449e14cbb30e92f56e3fbb422b614 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,6 +220,7 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, + pub log_in_button: Interactive, pub channel_hash: Icon, pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 6c88e5032c04b40ca25b2e23ed47cdc68786f23d..e447a43d5572107c6b4aae5aff101c510b3b34c0 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -14,7 +14,7 @@ pub trait Panel: View { fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext); fn size(&self, cx: &WindowContext) -> f32; fn set_size(&mut self, size: f32, cx: &mut ViewContext); - fn icon_path(&self) -> &'static str; + fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>; fn icon_tooltip(&self) -> (String, Option>); fn icon_label(&self, _: &WindowContext) -> Option { None @@ -51,7 +51,7 @@ pub trait PanelHandle { fn set_active(&self, active: bool, cx: &mut WindowContext); fn size(&self, cx: &WindowContext) -> f32; fn set_size(&self, size: f32, cx: &mut WindowContext); - fn icon_path(&self, cx: &WindowContext) -> &'static str; + fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>; fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option>); fn icon_label(&self, cx: &WindowContext) -> Option; fn has_focus(&self, cx: &WindowContext) -> bool; @@ -98,8 +98,8 @@ where self.update(cx, |this, cx| this.set_active(active, cx)) } - fn icon_path(&self, cx: &WindowContext) -> &'static str { - self.read(cx).icon_path() + fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> { + self.read(cx).icon_path(cx) } fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option>) { @@ -490,8 +490,9 @@ impl View for PanelButtons { .map(|item| (item.panel.clone(), item.context_menu.clone())) .collect::>(); Flex::row() - .with_children(panels.into_iter().enumerate().map( + .with_children(panels.into_iter().enumerate().filter_map( |(panel_ix, (view, context_menu))| { + let icon_path = view.icon_path(cx)?; let is_active = is_open && panel_ix == active_ix; let (tooltip, tooltip_action) = if is_active { ( @@ -505,94 +506,96 @@ impl View for PanelButtons { } else { view.icon_tooltip(cx) }; - Stack::new() - .with_child( - MouseEventHandler::::new(panel_ix, cx, |state, cx| { - let style = button_style.in_state(is_active); - - let style = style.style_for(state); - Flex::row() - .with_child( - Svg::new(view.icon_path(cx)) - .with_color(style.icon_color) - .constrained() - .with_width(style.icon_size) - .aligned(), - ) - .with_children(if let Some(label) = view.icon_label(cx) { - Some( - Label::new(label, style.label.text.clone()) - .contained() - .with_style(style.label.container) + Some( + Stack::new() + .with_child( + MouseEventHandler::::new(panel_ix, cx, |state, cx| { + let style = button_style.in_state(is_active); + + let style = style.style_for(state); + Flex::row() + .with_child( + Svg::new(icon_path) + .with_color(style.icon_color) + .constrained() + .with_width(style.icon_size) .aligned(), ) - } else { - None - }) - .constrained() - .with_height(style.icon_size) - .contained() - .with_style(style.container) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, { - let tooltip_action = - tooltip_action.as_ref().map(|action| action.boxed_clone()); - move |_, this, cx| { - if let Some(tooltip_action) = &tooltip_action { - let window_id = cx.window_id(); - let view_id = this.workspace.id(); - let tooltip_action = tooltip_action.boxed_clone(); - cx.spawn(|_, mut cx| async move { - cx.dispatch_action( - window_id, - view_id, - &*tooltip_action, + .with_children(if let Some(label) = view.icon_label(cx) { + Some( + Label::new(label, style.label.text.clone()) + .contained() + .with_style(style.label.container) + .aligned(), ) - .ok(); + } else { + None }) - .detach(); - } - } - }) - .on_click(MouseButton::Right, { - let view = view.clone(); - let menu = context_menu.clone(); - move |_, _, cx| { - const POSITIONS: [DockPosition; 3] = [ - DockPosition::Left, - DockPosition::Right, - DockPosition::Bottom, - ]; - - menu.update(cx, |menu, cx| { - let items = POSITIONS - .into_iter() - .filter(|position| { - *position != dock_position - && view.position_is_valid(*position, cx) - }) - .map(|position| { - let view = view.clone(); - ContextMenuItem::handler( - format!("Dock {}", position.to_label()), - move |cx| view.set_position(position, cx), + .constrained() + .with_height(style.icon_size) + .contained() + .with_style(style.container) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, { + let tooltip_action = + tooltip_action.as_ref().map(|action| action.boxed_clone()); + move |_, this, cx| { + if let Some(tooltip_action) = &tooltip_action { + let window_id = cx.window_id(); + let view_id = this.workspace.id(); + let tooltip_action = tooltip_action.boxed_clone(); + cx.spawn(|_, mut cx| async move { + cx.dispatch_action( + window_id, + view_id, + &*tooltip_action, ) + .ok(); }) - .collect(); - menu.show(Default::default(), menu_corner, items, cx); - }) - } - }) - .with_tooltip::( - panel_ix, - tooltip, - tooltip_action, - tooltip_style.clone(), - cx, - ), - ) - .with_child(ChildView::new(&context_menu, cx)) + .detach(); + } + } + }) + .on_click(MouseButton::Right, { + let view = view.clone(); + let menu = context_menu.clone(); + move |_, _, cx| { + const POSITIONS: [DockPosition; 3] = [ + DockPosition::Left, + DockPosition::Right, + DockPosition::Bottom, + ]; + + menu.update(cx, |menu, cx| { + let items = POSITIONS + .into_iter() + .filter(|position| { + *position != dock_position + && view.position_is_valid(*position, cx) + }) + .map(|position| { + let view = view.clone(); + ContextMenuItem::handler( + format!("Dock {}", position.to_label()), + move |cx| view.set_position(position, cx), + ) + }) + .collect(); + menu.show(Default::default(), menu_corner, items, cx); + }) + } + }) + .with_tooltip::( + panel_ix, + tooltip, + tooltip_action, + tooltip_style.clone(), + cx, + ), + ) + .with_child(ChildView::new(&context_menu, cx)), + ) }, )) .contained() @@ -702,8 +705,8 @@ pub mod test { self.size = size; } - fn icon_path(&self) -> &'static str { - "icons/test_panel.svg" + fn icon_path(&self, _: &WindowContext) -> Option<&'static str> { + Some("icons/test_panel.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index f24468dca61715442fb5aedd9ceee3e075dcd374..2c543356b0de72c694898f77e8ff0570513e8347 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -67,6 +67,37 @@ export default function contacts_panel(): any { return { channel_modal: channel_modal(), + log_in_button: interactive({ + base: { + background: background(theme.middle), + border: border(theme.middle, "active"), + corner_radius: 4, + margin: { + top: 16, + left: 16, + right: 16, + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(theme.middle, "sans", "default", { size: "sm" }), + }, + state: { + hovered: { + ...text(theme.middle, "sans", "default", { size: "sm" }), + background: background(theme.middle, "hovered"), + border: border(theme.middle, "active"), + }, + clicked: { + ...text(theme.middle, "sans", "default", { size: "sm" }), + background: background(theme.middle, "pressed"), + border: border(theme.middle, "active"), + }, + }, + }), background: background(layer), padding: { top: 12, From bedf60b6b28d7aeae35ba4583ee8420911a77134 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 16:45:13 -0700 Subject: [PATCH 049/105] Improve local collaboration script to accept a zed impersonate Gate channels UI behind a flag co-authored-by: max --- Cargo.lock | 1 + crates/collab_ui/Cargo.toml | 2 +- crates/collab_ui/src/collab_panel.rs | 149 ++++++++++++++------------- script/start-local-collaboration | 2 +- 4 files changed, 80 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbd7c8d3042d8a205190c46080b49c246a729d13..26f71da741f74bf9c252a9e172a841755f0d32a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1568,6 +1568,7 @@ dependencies = [ "serde", "serde_derive", "settings", + "staff_mode", "theme", "theme_selector", "util", diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 2ceac649ecf9e013349acbb3610fb4f880ea10f7..471608c43ec86a1afce15bc5552bdd58b7d0cd86 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -38,6 +38,7 @@ picker = { path = "../picker" } project = { path = "../project" } recent_projects = {path = "../recent_projects"} settings = { path = "../settings" } +staff_mode = {path = "../staff_mode"} theme = { path = "../theme" } theme_selector = { path = "../theme_selector" } vcs_menu = { path = "../vcs_menu" } @@ -45,7 +46,6 @@ util = { path = "../util" } workspace = { path = "../workspace" } zed-actions = {path = "../zed-actions"} - anyhow.workspace = true futures.workspace = true log.workspace = true diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index d8e26823164ea90216aefa6ac382c976fcca6047..13d14f51accb5e2e45570cc5a0a30772d1e048be 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -31,6 +31,7 @@ use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings} use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; +use staff_mode::StaffMode; use std::{mem, sync::Arc}; use theme::IconButton; use util::{ResultExt, TryFutureExt}; @@ -347,6 +348,8 @@ impl CollabPanel { .push(cx.observe(&this.channel_store, |this, _, cx| this.update_entries(cx))); this.subscriptions .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx))); + this.subscriptions + .push(cx.observe_global::(move |this, cx| this.update_entries(cx))); this }) @@ -516,79 +519,76 @@ impl CollabPanel { } } - self.entries.push(ListEntry::Header(Section::Channels, 0)); + let mut request_entries = Vec::new(); + if self.include_channels_section(cx) { + self.entries.push(ListEntry::Header(Section::Channels, 0)); - let channels = channel_store.channels(); - if !(channels.is_empty() && self.channel_editing_state.is_none()) { - self.match_candidates.clear(); - self.match_candidates - .extend( - channels - .iter() - .enumerate() - .map(|(ix, channel)| StringMatchCandidate { + let channels = channel_store.channels(); + if !(channels.is_empty() && self.channel_editing_state.is_none()) { + self.match_candidates.clear(); + self.match_candidates + .extend(channels.iter().enumerate().map(|(ix, channel)| { + StringMatchCandidate { id: ix, string: channel.name.clone(), char_bag: channel.name.chars().collect(), - }), - ); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - if let Some(state) = &self.channel_editing_state { - if state.parent_id.is_none() { - self.entries.push(ListEntry::ChannelEditor { depth: 0 }); - } - } - for mat in matches { - let channel = &channels[mat.candidate_id]; - self.entries.push(ListEntry::Channel(channel.clone())); + } + })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); if let Some(state) = &self.channel_editing_state { - if state.parent_id == Some(channel.id) { - self.entries.push(ListEntry::ChannelEditor { - depth: channel.depth + 1, - }); + if state.parent_id.is_none() { + self.entries.push(ListEntry::ChannelEditor { depth: 0 }); + } + } + for mat in matches { + let channel = &channels[mat.candidate_id]; + self.entries.push(ListEntry::Channel(channel.clone())); + if let Some(state) = &self.channel_editing_state { + if state.parent_id == Some(channel.id) { + self.entries.push(ListEntry::ChannelEditor { + depth: channel.depth + 1, + }); + } } } } - } - let mut request_entries = Vec::new(); - let channel_invites = channel_store.channel_invitations(); - if !channel_invites.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { - StringMatchCandidate { - id: ix, - string: channel.name.clone(), - char_bag: channel.name.chars().collect(), - } + let channel_invites = channel_store.channel_invitations(); + if !channel_invites.is_empty() { + self.match_candidates.clear(); + self.match_candidates + .extend(channel_invites.iter().enumerate().map(|(ix, channel)| { + StringMatchCandidate { + id: ix, + string: channel.name.clone(), + char_bag: channel.name.chars().collect(), + } + })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + request_entries.extend(matches.iter().map(|mat| { + ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone()) })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - request_entries.extend( - matches - .iter() - .map(|mat| ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())), - ); - if !request_entries.is_empty() { - self.entries - .push(ListEntry::Header(Section::ChannelInvites, 1)); - if !self.collapsed_sections.contains(&Section::ChannelInvites) { - self.entries.append(&mut request_entries); + if !request_entries.is_empty() { + self.entries + .push(ListEntry::Header(Section::ChannelInvites, 1)); + if !self.collapsed_sections.contains(&Section::ChannelInvites) { + self.entries.append(&mut request_entries); + } } } } @@ -686,16 +686,9 @@ impl CollabPanel { executor.clone(), )); - let (mut online_contacts, offline_contacts) = matches + let (online_contacts, offline_contacts) = matches .iter() .partition::, _>(|mat| contacts[mat.candidate_id].online); - if let Some(room) = ActiveCall::global(cx).read(cx).room() { - let room = room.read(cx); - online_contacts.retain(|contact| { - let contact = &contacts[contact.candidate_id]; - !room.contains_participant(contact.user.id) - }); - } for (matches, section) in [ (online_contacts, Section::Online), @@ -1534,6 +1527,14 @@ impl CollabPanel { .into_any() } + fn include_channels_section(&self, cx: &AppContext) -> bool { + if cx.has_global::() { + cx.global::().0 + } else { + false + } + } + fn deploy_channel_context_menu( &mut self, position: Vector2F, @@ -1878,8 +1879,12 @@ impl View for CollabPanel { }) .on_click(MouseButton::Left, |_, this, cx| { let client = this.client.clone(); - cx.spawn(|_, cx| async move { - client.authenticate_and_connect(true, &cx).await.log_err() + cx.spawn(|this, mut cx| async move { + client.authenticate_and_connect(true, &cx).await.log_err(); + + this.update(&mut cx, |_, cx| { + cx.notify(); + }) }) .detach(); }) diff --git a/script/start-local-collaboration b/script/start-local-collaboration index b702fb4e02f9d0e3ae2a70ca99054b7bea2a711b..a5836ff776aacf98a09cdd11252119862ea10190 100755 --- a/script/start-local-collaboration +++ b/script/start-local-collaboration @@ -53,6 +53,6 @@ sleep 0.5 # Start the two Zed child processes. Open the given paths with the first instance. trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT -ZED_IMPERSONATE=${username_1} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ & +ZED_IMPERSONATE=${ZED_IMPERSONATE:=${username_1}} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ & SECOND=true ZED_IMPERSONATE=${username_2} ZED_WINDOW_POSITION=${position_2} target/debug/Zed & wait From fa71de8842c512dfb797b3f87886acbd2c9ba9eb Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 7 Aug 2023 17:14:09 -0700 Subject: [PATCH 050/105] Tune UX for context menus Co-authored-by: max --- crates/client/src/user.rs | 16 ++++++- crates/collab_ui/src/collab_panel.rs | 45 +++++++++++++------ .../src/collab_panel/channel_modal.rs | 16 ++++--- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 4c2721ffebbfd78e88c5d7fc656d1ab0ca1a1c79..be11d1fb442b8d69228f867156b3b7e6c08b0b66 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -165,17 +165,29 @@ impl UserStore { }); current_user_tx.send(user).await.ok(); + + this.update(&mut cx, |_, cx| { + cx.notify(); + }); } } Status::SignedOut => { current_user_tx.send(None).await.ok(); if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, _| this.clear_contacts()).await; + this.update(&mut cx, |this, cx| { + cx.notify(); + this.clear_contacts() + }) + .await; } } Status::ConnectionLost => { if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, _| this.clear_contacts()).await; + this.update(&mut cx, |this, cx| { + cx.notify(); + this.clear_contacts() + }) + .await; } } _ => {} diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 13d14f51accb5e2e45570cc5a0a30772d1e048be..e457f8c750c319c365d6197d96ae16847fb24c5b 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -4,7 +4,7 @@ mod panel_settings; use anyhow::Result; use call::ActiveCall; -use client::{proto::PeerId, Channel, ChannelStore, Client, Contact, User, UserStore}; +use client::{proto::PeerId, Channel, ChannelId, ChannelStore, Client, Contact, User, UserStore}; use contact_finder::build_contact_finder; use context_menu::{ContextMenu, ContextMenuItem}; use db::kvp::KEY_VALUE_STORE; @@ -55,13 +55,21 @@ struct NewChannel { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct AddMember { +struct InviteMembers { + channel_id: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct ManageMembers { channel_id: u64, } actions!(collab_panel, [ToggleFocus]); -impl_actions!(collab_panel, [RemoveChannel, NewChannel, AddMember]); +impl_actions!( + collab_panel, + [RemoveChannel, NewChannel, InviteMembers, ManageMembers] +); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; @@ -76,7 +84,8 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::confirm); cx.add_action(CollabPanel::remove_channel); cx.add_action(CollabPanel::new_subchannel); - cx.add_action(CollabPanel::add_member); + cx.add_action(CollabPanel::invite_members); + cx.add_action(CollabPanel::manage_members); } #[derive(Debug, Default)] @@ -325,6 +334,7 @@ impl CollabPanel { client: workspace.app_state().client.clone(), list_state, }; + this.update_entries(cx); // Update the dock position when the setting changes. @@ -1549,7 +1559,8 @@ impl CollabPanel { vec![ ContextMenuItem::action("New Channel", NewChannel { channel_id }), ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), - ContextMenuItem::action("Add member", AddMember { channel_id }), + ContextMenuItem::action("Manage members", ManageMembers { channel_id }), + ContextMenuItem::action("Invite members", InviteMembers { channel_id }), ], cx, ); @@ -1710,8 +1721,20 @@ impl CollabPanel { cx.notify(); } - fn add_member(&mut self, action: &AddMember, cx: &mut ViewContext) { - let channel_id = action.channel_id; + fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext) { + self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx); + } + + fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext) { + self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx); + } + + fn show_channel_modal( + &mut self, + channel_id: ChannelId, + mode: channel_modal::Mode, + cx: &mut ViewContext, + ) { let workspace = self.workspace.clone(); let user_store = self.user_store.clone(); let channel_store = self.channel_store.clone(); @@ -1728,7 +1751,7 @@ impl CollabPanel { user_store.clone(), channel_store.clone(), channel_id, - channel_modal::Mode::InviteMembers, + mode, members, cx, ) @@ -1879,12 +1902,8 @@ impl View for CollabPanel { }) .on_click(MouseButton::Left, |_, this, cx| { let client = this.client.clone(); - cx.spawn(|this, mut cx| async move { + cx.spawn(|_, cx| async move { client.authenticate_and_connect(true, &cx).await.log_err(); - - this.update(&mut cx, |_, cx| { - cx.notify(); - }) }) .detach(); }) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 8747d9a0afea44395fb72af7d5e67f5b9fb6c67f..7ce830b22f87cbecd6ff193319f96d88c58527ac 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -337,7 +337,7 @@ impl PickerDelegate for ChannelModalDelegate { Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx), Mode::InviteMembers => match self.member_status(selected_user.id, cx) { Some(proto::channel_member::Kind::Invitee) => { - self.remove_member(selected_user.id, cx); + self.remove_selected_member(cx); } Some(proto::channel_member::Kind::AncestorMember) | None => { self.invite_member(selected_user, cx) @@ -502,6 +502,7 @@ impl ChannelModalDelegate { if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) { member.admin = admin; } + cx.focus_self(); cx.notify(); }) }) @@ -511,11 +512,7 @@ impl ChannelModalDelegate { fn remove_selected_member(&mut self, cx: &mut ViewContext>) -> Option<()> { let (user, _) = self.user_at_index(self.selected_index)?; - self.remove_member(user.id, cx); - Some(()) - } - - fn remove_member(&mut self, user_id: u64, cx: &mut ViewContext>) { + let user_id = user.id; let update = self.channel_store.update(cx, |store, cx| { store.remove_member(self.channel_id, user_id, cx) }); @@ -534,10 +531,17 @@ impl ChannelModalDelegate { true }) } + + this.selected_index = this + .selected_index + .min(this.matching_member_indices.len() - 1); + + cx.focus_self(); cx.notify(); }) }) .detach_and_log_err(cx); + Some(()) } fn invite_member(&mut self, user: Arc, cx: &mut ViewContext>) { From 299906346e0268deca783b415ea5d35c7bc0b0a0 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 7 Aug 2023 18:04:41 -0700 Subject: [PATCH 051/105] Change collab panel icon --- crates/collab_ui/src/collab_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index e457f8c750c319c365d6197d96ae16847fb24c5b..f745420eeb6b05df1193ff88bde63a8cb7ac3d9f 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1088,7 +1088,7 @@ impl CollabPanel { MouseEventHandler::::new(0, cx, |_, _| { render_icon_button( theme.collab_panel.add_contact_button.in_state(is_selected), - "icons/user_plus_16.svg", + "icons/plus_16.svg", ) }) .with_cursor_style(CursorStyle::PointingHand) @@ -1993,7 +1993,7 @@ impl Panel for CollabPanel { fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { settings::get::(cx) .button - .then(|| "icons/radix/person.svg") + .then(|| "icons/speech_bubble_12.svg") } fn icon_tooltip(&self) -> (String, Option>) { From 17c9b4ca968c8605e69c5840fe838feb2735d894 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 8 Aug 2023 10:03:55 -0700 Subject: [PATCH 052/105] Fix tests --- crates/client/src/channel_store_tests.rs | 11 ++++++----- crates/zed/src/zed.rs | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs index 7f31243dadc18b635cf8e804bbe49093fd25e21f..69d5fed70d5358b327ca82edd8755e142b3e34e9 100644 --- a/crates/client/src/channel_store_tests.rs +++ b/crates/client/src/channel_store_tests.rs @@ -35,8 +35,8 @@ fn test_update_channels(cx: &mut AppContext) { &channel_store, &[ // - (0, "a", true), - (0, "b", false), + (0, "a", false), + (0, "b", true), ], cx, ); @@ -65,9 +65,9 @@ fn test_update_channels(cx: &mut AppContext) { assert_channels( &channel_store, &[ - (0, "a", true), - (1, "y", true), - (0, "b", false), + (0, "a", false), + (1, "y", false), + (0, "b", true), (1, "x", false), ], cx, @@ -82,6 +82,7 @@ fn update_channels( channel_store.update(cx, |store, cx| store.update_channels(message, cx)); } +#[track_caller] fn assert_channels( channel_store: &ModelHandle, expected_channels: &[(usize, &str, bool)], diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 655f0ec84c46c1a6cbbe756a2d6722bbe43de6b4..f435d9a721162a4abf8a81f509380dc1e348b9cb 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2401,6 +2401,7 @@ mod tests { language::init(cx); editor::init(cx); project_panel::init_settings(cx); + collab_ui::init(&app_state, cx); pane::init(cx); project_panel::init((), cx); terminal_view::init(cx); From 6a7245b92bdf5ad2be361569953fe8ed56d0d53f Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 8 Aug 2023 10:44:44 -0700 Subject: [PATCH 053/105] Fix positioning on face piles, fix panic on member invite removal --- crates/collab_ui/src/collab_panel/channel_modal.rs | 2 +- crates/collab_ui/src/face_pile.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 7ce830b22f87cbecd6ff193319f96d88c58527ac..09be3798a6037bc2c9acb18c47acaac0a9f044d5 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -534,7 +534,7 @@ impl ChannelModalDelegate { this.selected_index = this .selected_index - .min(this.matching_member_indices.len() - 1); + .min(this.matching_member_indices.len().saturating_sub(1)); cx.focus_self(); cx.notify(); diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs index 30fcb9750678b24ecb949341d9209e6489ff21e4..b6047614888e4bbe59d9860b8831108e3822e239 100644 --- a/crates/collab_ui/src/face_pile.rs +++ b/crates/collab_ui/src/face_pile.rs @@ -37,12 +37,18 @@ impl Element for FacePile { debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY); let mut width = 0.; + let mut max_height = 0.; for face in &mut self.faces { - width += face.layout(constraint, view, cx).x(); + 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, constraint.max.y()), ()) + ( + Vector2F::new(width, max_height.clamp(1., constraint.max.y())), + (), + ) } fn paint( From d00f6a490c4a089a249fcd23ddd498eb8c79f71c Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 8 Aug 2023 11:47:13 -0700 Subject: [PATCH 054/105] Fix a bug where channel invitations would show up in the channels section Block non-members from reading channel information WIP: Make sure Arc::make_mut() works --- crates/client/src/channel_store.rs | 7 ++- crates/collab/src/db.rs | 60 ++++++++++++++------- crates/collab/src/db/tests.rs | 3 ++ crates/collab/src/rpc.rs | 32 +++++++---- crates/collab/src/tests/channel_tests.rs | 67 ++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 317fbd118973ef93d8ef1cf69e306d061f3cad38..93b96fc6291b89658692a85f875b162db89f9235 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -301,8 +301,10 @@ impl ChannelStore { .iter_mut() .find(|c| c.id == channel.id) { - let existing_channel = Arc::make_mut(existing_channel); + let existing_channel = Arc::get_mut(existing_channel) + .expect("channel is shared, update would have been lost"); existing_channel.name = channel.name; + existing_channel.user_is_admin = channel.user_is_admin; continue; } @@ -320,7 +322,8 @@ impl ChannelStore { for channel in payload.channels { if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { - let existing_channel = Arc::make_mut(existing_channel); + let existing_channel = Arc::get_mut(existing_channel) + .expect("channel is shared, update would have been lost"); existing_channel.name = channel.name; existing_channel.user_is_admin = channel.user_is_admin; continue; diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index c3ffc126342e531e08b39bb25ec5e6c5a591a41f..eb40587ea7dc262d730cbc246a5cb5336b4ac480 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3601,7 +3601,7 @@ impl Database { ) .one(&*tx) .await? - .ok_or_else(|| anyhow!("user is not a channel member"))?; + .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?; Ok(()) } @@ -3621,7 +3621,7 @@ impl Database { ) .one(&*tx) .await? - .ok_or_else(|| anyhow!("user is not a channel admin"))?; + .ok_or_else(|| anyhow!("user is not a channel admin or channel does not exist"))?; Ok(()) } @@ -3723,31 +3723,53 @@ impl Database { Ok(parents_by_child_id) } + /// Returns the channel with the given ID and: + /// - true if the user is a member + /// - false if the user hasn't accepted the invitation yet pub async fn get_channel( &self, channel_id: ChannelId, user_id: UserId, - ) -> Result> { + ) -> Result> { self.transaction(|tx| async move { let tx = tx; + let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?; - let user_is_admin = channel_member::Entity::find() - .filter( - channel_member::Column::ChannelId - .eq(channel_id) - .and(channel_member::Column::UserId.eq(user_id)) - .and(channel_member::Column::Admin.eq(true)), - ) - .count(&*tx) - .await? - > 0; - Ok(channel.map(|channel| Channel { - id: channel.id, - name: channel.name, - user_is_admin, - parent_id: None, - })) + if let Some(channel) = channel { + if self + .check_user_is_channel_member(channel_id, user_id, &*tx) + .await + .is_err() + { + return Ok(None); + } + + let channel_membership = channel_member::Entity::find() + .filter( + channel_member::Column::ChannelId + .eq(channel_id) + .and(channel_member::Column::UserId.eq(user_id)), + ) + .one(&*tx) + .await?; + + let (user_is_admin, is_accepted) = channel_membership + .map(|membership| (membership.admin, membership.accepted)) + .unwrap_or((false, false)); + + Ok(Some(( + Channel { + id: channel.id, + name: channel.name, + user_is_admin, + parent_id: None, + }, + is_accepted, + ))) + } else { + Ok(None) + } }) .await } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index efc35a5c249f6ab7ea1081665cd9c4196f0f482f..cdcde3332c54b7dfeda2fa6082b5a6c016d000c1 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -915,6 +915,9 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap(); + // Make sure that people cannot read channels they haven't been invited to + assert!(db.get_channel(zed_id, b_id).await.unwrap().is_none()); + db.invite_channel_member(zed_id, b_id, a_id, false) .await .unwrap(); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index f1fd97db415b31b11e3b1219bae8f8313fc4b163..a24db6be81f755641c244de8bff3444c2fbbb06e 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2210,14 +2210,15 @@ async fn invite_channel_member( ) -> Result<()> { let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - let channel = db - .get_channel(channel_id, session.user_id) - .await? - .ok_or_else(|| anyhow!("channel not found"))?; let invitee_id = UserId::from_proto(request.user_id); db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) .await?; + let (channel, _) = db + .get_channel(channel_id, session.user_id) + .await? + .ok_or_else(|| anyhow!("channel not found"))?; + let mut update = proto::UpdateChannels::default(); update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), @@ -2275,18 +2276,27 @@ async fn set_channel_member_admin( db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin) .await?; - let channel = db + let (channel, has_accepted) = db .get_channel(channel_id, member_id) .await? .ok_or_else(|| anyhow!("channel not found"))?; let mut update = proto::UpdateChannels::default(); - update.channels.push(proto::Channel { - id: channel.id.to_proto(), - name: channel.name, - parent_id: None, - user_is_admin: request.admin, - }); + if has_accepted { + update.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + user_is_admin: request.admin, + }); + } else { + update.channel_invitations.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + parent_id: None, + user_is_admin: request.admin, + }); + } for connection_id in session .connection_pool() diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 88d88a40fd8f0f835363c9d42cb278ef6478439c..9723b18394864454d42920de853556f2a7fd4860 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -584,3 +584,70 @@ async fn test_channel_jumping(deterministic: Arc, cx_a: &mut Test ); }); } + +#[gpui::test] +async fn test_permissions_update_while_invited( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let rust_id = server + .make_channel("rust", (&client_a, cx_a), &mut []) + .await; + + client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.invite_member(rust_id, client_b.user_id().unwrap(), false, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channel_invitations(), + &[Arc::new(Channel { + id: rust_id, + name: "rust".to_string(), + parent_id: None, + user_is_admin: false, + depth: 0, + })], + ); + + assert_eq!(channels.channels(), &[],); + }); + + // Update B's invite before they've accepted it + client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.set_member_admin(rust_id, client_b.user_id().unwrap(), true, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channel_invitations(), + &[Arc::new(Channel { + id: rust_id, + name: "rust".to_string(), + parent_id: None, + user_is_admin: true, + depth: 0, + })], + ); + + assert_eq!(channels.channels(), &[],); + }); +} From b708824d3796306dc7de4734cd0f8440e83de4af Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 8 Aug 2023 12:46:13 -0700 Subject: [PATCH 055/105] Position and style the channel editor correctly Fix a bug where some channel updates would be lost Add channel name sanitization before storing in the database --- crates/client/src/channel_store.rs | 34 +++++++++++++++++++------ crates/collab/src/db.rs | 1 + crates/collab_ui/src/collab_panel.rs | 36 +++++++++++++++++++++++---- crates/theme/src/theme.rs | 2 ++ crates/util/src/util.rs | 15 +++++++++++ styles/src/style_tree/collab_panel.ts | 7 +++++- 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 93b96fc6291b89658692a85f875b162db89f9235..1beb1bc8ea6debe84d5209d5b399e1e25f9991e3 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -4,8 +4,10 @@ use anyhow::Result; use collections::HashMap; use collections::HashSet; use futures::Future; +use futures::StreamExt; use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, TypedEnvelope}; +use std::mem; use std::sync::Arc; pub type ChannelId = u64; @@ -19,6 +21,7 @@ pub struct ChannelStore { client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, + _maintain_user: Task<()>, } #[derive(Clone, Debug, PartialEq)] @@ -55,6 +58,20 @@ impl ChannelStore { let rpc_subscription = client.add_message_handler(cx.handle(), Self::handle_update_channels); + let mut current_user = user_store.read(cx).watch_current_user(); + let maintain_user = cx.spawn(|this, mut cx| async move { + while let Some(current_user) = current_user.next().await { + if current_user.is_none() { + this.update(&mut cx, |this, cx| { + this.channels.clear(); + this.channel_invitations.clear(); + this.channel_participants.clear(); + this.outgoing_invites.clear(); + cx.notify(); + }); + } + } + }); Self { channels: vec![], channel_invitations: vec![], @@ -63,6 +80,7 @@ impl ChannelStore { client, user_store, _rpc_subscription: rpc_subscription, + _maintain_user: maintain_user, } } @@ -301,10 +319,10 @@ impl ChannelStore { .iter_mut() .find(|c| c.id == channel.id) { - let existing_channel = Arc::get_mut(existing_channel) - .expect("channel is shared, update would have been lost"); - existing_channel.name = channel.name; - existing_channel.user_is_admin = channel.user_is_admin; + util::make_arc_mut(existing_channel, |new_existing_channel| { + new_existing_channel.name = channel.name; + new_existing_channel.user_is_admin = channel.user_is_admin; + }); continue; } @@ -322,10 +340,10 @@ impl ChannelStore { for channel in payload.channels { if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { - let existing_channel = Arc::get_mut(existing_channel) - .expect("channel is shared, update would have been lost"); - existing_channel.name = channel.name; - existing_channel.user_is_admin = channel.user_is_admin; + util::make_arc_mut(existing_channel, |new_existing_channel| { + new_existing_channel.name = channel.name; + new_existing_channel.user_is_admin = channel.user_is_admin; + }); continue; } diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index eb40587ea7dc262d730cbc246a5cb5336b4ac480..ed5e7e8e3d5a4ba0e06bf2847bc6877ec97f60bf 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3155,6 +3155,7 @@ impl Database { live_kit_room: &str, creator_id: UserId, ) -> Result { + let name = name.trim().trim_start_matches('#'); self.transaction(move |tx| async move { if let Some(parent) = parent { self.check_user_is_channel_admin(parent, creator_id, &*tx) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index f745420eeb6b05df1193ff88bde63a8cb7ac3d9f..2b39678f5e32d1685a3c865ab9fbe040da3e5a5e 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -308,7 +308,7 @@ impl CollabPanel { cx, ), ListEntry::ChannelEditor { depth } => { - this.render_channel_editor(&theme.collab_panel, *depth, cx) + this.render_channel_editor(&theme, *depth, cx) } } }); @@ -1280,11 +1280,37 @@ impl CollabPanel { fn render_channel_editor( &self, - _theme: &theme::CollabPanel, - _depth: usize, + theme: &theme::Theme, + depth: usize, cx: &AppContext, ) -> AnyElement { - ChildView::new(&self.channel_name_editor, cx).into_any() + Flex::row() + .with_child( + Svg::new("icons/channel_hash.svg") + .with_color(theme.collab_panel.channel_hash.color) + .constrained() + .with_width(theme.collab_panel.channel_hash.width) + .aligned() + .left(), + ) + .with_child( + ChildView::new(&self.channel_name_editor, cx) + .contained() + .with_style(theme.collab_panel.channel_editor) + .flex(1.0, true), + ) + .align_children_center() + .contained() + .with_padding_left( + theme.collab_panel.contact_row.default_style().padding.left + + theme.collab_panel.channel_indent * depth as f32, + ) + .contained() + .with_style(gpui::elements::ContainerStyle { + background_color: Some(theme.editor.background), + ..Default::default() + }) + .into_any() } fn render_channel( @@ -1331,7 +1357,7 @@ impl CollabPanel { .constrained() .with_height(theme.row_height) .contained() - .with_style(*theme.contact_row.in_state(is_selected).style_for(state)) + .with_style(*theme.contact_row.style_for(is_selected, state)) .with_padding_left( theme.contact_row.default_style().padding.left + theme.channel_indent * channel.depth as f32, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index c554f77fe42449e14cbb30e92f56e3fbb422b614..cf8da6233a4e4950d106d637275b3e59f50ef436 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -221,6 +221,7 @@ pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, pub log_in_button: Interactive, + pub channel_editor: ContainerStyle, pub channel_hash: Icon, pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, @@ -885,6 +886,7 @@ impl Toggleable { pub fn active_state(&self) -> &T { self.in_state(true) } + pub fn inactive_state(&self) -> &T { self.in_state(false) } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index c8beb86aeff44a75482531d22a6e8b0c07155fa6..2766cee295fe5cf3d97b7f97ce16003355a75de6 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -9,9 +9,11 @@ pub mod test; use std::{ borrow::Cow, cmp::{self, Ordering}, + mem, ops::{AddAssign, Range, RangeInclusive}, panic::Location, pin::Pin, + sync::Arc, task::{Context, Poll}, }; @@ -118,6 +120,19 @@ pub fn merge_non_null_json_value_into(source: serde_json::Value, target: &mut se } } +/// Mutates through the arc if no other references exist, +/// otherwise clones the value and swaps out the reference with a new Arc +/// Useful for mutating the elements of a list while using iter_mut() +pub fn make_arc_mut(arc: &mut Arc, mutate: impl FnOnce(&mut T)) { + if let Some(t) = Arc::get_mut(arc) { + mutate(t); + return; + } + let mut new_t = (**arc).clone(); + mutate(&mut new_t); + mem::swap(&mut Arc::new(new_t), arc); +} + pub trait ResultExt { type Ok; diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 2c543356b0de72c694898f77e8ff0570513e8347..a859f6d6700a772a2ba6f51311eacba9e6b2d6f1 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -316,6 +316,11 @@ export default function contacts_panel(): any { }, }, }), - face_overlap: 8 + face_overlap: 8, + channel_editor: { + padding: { + left: 8, + } + } } } From bbe4a9b38881824433453e654da5092258c02024 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 8 Aug 2023 12:46:13 -0700 Subject: [PATCH 056/105] Position and style the channel editor correctly Fix a bug where some channel updates would be lost Add channel name sanitization before storing in the database --- assets/keymaps/default.json | 8 +++ crates/collab_ui/src/collab_panel.rs | 96 ++++++++++++++++++++++++---- crates/menu/src/menu.rs | 3 +- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 11cc50a03e3f17757083e41e8edc9cc3436e6cb3..f4d36ee95bbe666afbff05223c9169274ba1ca72 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -13,6 +13,7 @@ "cmd-up": "menu::SelectFirst", "cmd-down": "menu::SelectLast", "enter": "menu::Confirm", + "ctrl-enter": "menu::ShowContextMenu", "cmd-enter": "menu::SecondaryConfirm", "escape": "menu::Cancel", "ctrl-c": "menu::Cancel", @@ -550,6 +551,13 @@ "alt-shift-f": "project_panel::NewSearchInDirectory" } }, + { + "context": "CollabPanel", + "bindings": { + "ctrl-backspace": "collab_panel::Remove", + "space": "menu::Confirm" + } + }, { "context": "ChannelModal", "bindings": { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 2b39678f5e32d1685a3c865ab9fbe040da3e5a5e..85e0d80cce4dccf203b47a6d6e0af2ef3e3cefbf 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -15,7 +15,7 @@ use gpui::{ actions, elements::{ Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState, - MouseEventHandler, Orientation, Padding, ParentElement, Stack, Svg, + MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, Stack, Svg, }, geometry::{ rect::RectF, @@ -64,7 +64,7 @@ struct ManageMembers { channel_id: u64, } -actions!(collab_panel, [ToggleFocus]); +actions!(collab_panel, [ToggleFocus, Remove, Secondary]); impl_actions!( collab_panel, @@ -82,7 +82,9 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::select_next); cx.add_action(CollabPanel::select_prev); cx.add_action(CollabPanel::confirm); - cx.add_action(CollabPanel::remove_channel); + cx.add_action(CollabPanel::remove); + cx.add_action(CollabPanel::remove_channel_action); + cx.add_action(CollabPanel::show_inline_context_menu); cx.add_action(CollabPanel::new_subchannel); cx.add_action(CollabPanel::invite_members); cx.add_action(CollabPanel::manage_members); @@ -113,6 +115,7 @@ pub struct CollabPanel { subscriptions: Vec, collapsed_sections: Vec
, workspace: WeakViewHandle, + context_menu_on_selected: bool, } #[derive(Serialize, Deserialize)] @@ -274,7 +277,26 @@ impl CollabPanel { ) } ListEntry::Channel(channel) => { - this.render_channel(&*channel, &theme.collab_panel, is_selected, cx) + let channel_row = this.render_channel( + &*channel, + &theme.collab_panel, + is_selected, + cx, + ); + + if is_selected && this.context_menu_on_selected { + Stack::new() + .with_child(channel_row) + .with_child( + ChildView::new(&this.context_menu, cx) + .aligned() + .bottom() + .right(), + ) + .into_any() + } else { + return channel_row; + } } ListEntry::ChannelInvite(channel) => Self::render_channel_invite( channel.clone(), @@ -332,6 +354,7 @@ impl CollabPanel { collapsed_sections: Vec::default(), workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), + context_menu_on_selected: true, list_state, }; @@ -1321,6 +1344,7 @@ impl CollabPanel { cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; + MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() .with_child( @@ -1367,7 +1391,7 @@ impl CollabPanel { this.join_channel(channel_id, cx); }) .on_click(MouseButton::Right, move |e, this, cx| { - this.deploy_channel_context_menu(e.position, channel_id, cx); + this.deploy_channel_context_menu(Some(e.position), channel_id, cx); }) .into_any() } @@ -1573,15 +1597,27 @@ impl CollabPanel { fn deploy_channel_context_menu( &mut self, - position: Vector2F, + position: Option, channel_id: u64, cx: &mut ViewContext, ) { if self.channel_store.read(cx).is_user_admin(channel_id) { + self.context_menu_on_selected = position.is_none(); + self.context_menu.update(cx, |context_menu, cx| { + context_menu.set_position_mode(if self.context_menu_on_selected { + OverlayPositionMode::Local + } else { + OverlayPositionMode::Window + }); + context_menu.show( - position, - gpui::elements::AnchorCorner::BottomLeft, + position.unwrap_or_default(), + if self.context_menu_on_selected { + gpui::elements::AnchorCorner::TopRight + } else { + gpui::elements::AnchorCorner::BottomLeft + }, vec![ ContextMenuItem::action("New Channel", NewChannel { channel_id }), ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), @@ -1591,6 +1627,8 @@ impl CollabPanel { cx, ); }); + + cx.notify(); } } @@ -1755,6 +1793,33 @@ impl CollabPanel { self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx); } + // TODO: Make join into a toggle + // TODO: Make enter work on channel editor + fn remove(&mut self, _: &Remove, cx: &mut ViewContext) { + if let Some(channel) = self.selected_channel() { + self.remove_channel(channel.id, cx) + } + } + + fn rename(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) {} + + fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext) { + let Some(channel) = self.selected_channel() else { + return; + }; + + self.deploy_channel_context_menu(None, channel.id, cx); + } + + fn selected_channel(&self) -> Option<&Arc> { + self.selection + .and_then(|ix| self.entries.get(ix)) + .and_then(|entry| match entry { + ListEntry::Channel(channel) => Some(channel), + _ => None, + }) + } + fn show_channel_modal( &mut self, channel_id: ChannelId, @@ -1788,8 +1853,11 @@ impl CollabPanel { .detach(); } - fn remove_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { - let channel_id = action.channel_id; + fn remove_channel_action(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { + self.remove_channel(action.channel_id, cx) + } + + fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { let channel_store = self.channel_store.clone(); if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) { let prompt_message = format!( @@ -1818,6 +1886,9 @@ impl CollabPanel { } } + // Should move to the filter editor if clicking on it + // Should move selection to the channel editor if activating it + fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext) { let user_store = self.user_store.clone(); let prompt_message = format!( @@ -1969,7 +2040,10 @@ impl View for CollabPanel { .with_width(self.size(cx)) .into_any(), ) - .with_child(ChildView::new(&self.context_menu, cx)) + .with_children( + (!self.context_menu_on_selected) + .then(|| ChildView::new(&self.context_menu, cx)), + ) .into_any() }) .on_click(MouseButton::Left, |_, _, cx| cx.focus_self()) diff --git a/crates/menu/src/menu.rs b/crates/menu/src/menu.rs index b0f1a9c6c8dd0d1e641b6d91f70e358ed64bc3d8..519ad1ecd0ec0935aa2730767878e3257d9fc6df 100644 --- a/crates/menu/src/menu.rs +++ b/crates/menu/src/menu.rs @@ -7,6 +7,7 @@ gpui::actions!( SelectPrev, SelectNext, SelectFirst, - SelectLast + SelectLast, + ShowContextMenu ] ); From 2605ae1ef52f38d23405a9469faaae46e5c6196b Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 8 Aug 2023 17:49:29 -0700 Subject: [PATCH 057/105] Use Arc::make_mut in ChannelStore Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 15 ++++++--------- crates/util/src/util.rs | 15 --------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 1beb1bc8ea6debe84d5209d5b399e1e25f9991e3..ec945ce0367c1da14543f41b4fe9733ef1fec90d 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -7,7 +7,6 @@ use futures::Future; use futures::StreamExt; use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, TypedEnvelope}; -use std::mem; use std::sync::Arc; pub type ChannelId = u64; @@ -319,10 +318,9 @@ impl ChannelStore { .iter_mut() .find(|c| c.id == channel.id) { - util::make_arc_mut(existing_channel, |new_existing_channel| { - new_existing_channel.name = channel.name; - new_existing_channel.user_is_admin = channel.user_is_admin; - }); + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; + existing_channel.user_is_admin = channel.user_is_admin; continue; } @@ -340,10 +338,9 @@ impl ChannelStore { for channel in payload.channels { if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { - util::make_arc_mut(existing_channel, |new_existing_channel| { - new_existing_channel.name = channel.name; - new_existing_channel.user_is_admin = channel.user_is_admin; - }); + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; + existing_channel.user_is_admin = channel.user_is_admin; continue; } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 2766cee295fe5cf3d97b7f97ce16003355a75de6..c8beb86aeff44a75482531d22a6e8b0c07155fa6 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -9,11 +9,9 @@ pub mod test; use std::{ borrow::Cow, cmp::{self, Ordering}, - mem, ops::{AddAssign, Range, RangeInclusive}, panic::Location, pin::Pin, - sync::Arc, task::{Context, Poll}, }; @@ -120,19 +118,6 @@ pub fn merge_non_null_json_value_into(source: serde_json::Value, target: &mut se } } -/// Mutates through the arc if no other references exist, -/// otherwise clones the value and swaps out the reference with a new Arc -/// Useful for mutating the elements of a list while using iter_mut() -pub fn make_arc_mut(arc: &mut Arc, mutate: impl FnOnce(&mut T)) { - if let Some(t) = Arc::get_mut(arc) { - mutate(t); - return; - } - let mut new_t = (**arc).clone(); - mutate(&mut new_t); - mem::swap(&mut Arc::new(new_t), arc); -} - pub trait ResultExt { type Ok; From a5cb4c6d52dd61efe47925ad6cb2eb299420eee4 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 9 Aug 2023 08:54:24 -0700 Subject: [PATCH 058/105] Fix selections and enter-to-create-file --- crates/collab_ui/src/collab_panel.rs | 61 ++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 85e0d80cce4dccf203b47a6d6e0af2ef3e3cefbf..b3b43dbabec735da5e7e338aab3629a4fc86d0f1 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -197,7 +197,7 @@ impl CollabPanel { if !query.is_empty() { this.selection.take(); } - this.update_entries(cx); + this.update_entries(false, cx); if !query.is_empty() { this.selection = this .entries @@ -220,7 +220,7 @@ impl CollabPanel { cx.subscribe(&channel_name_editor, |this, _, event, cx| { if let editor::Event::Blurred = event { this.take_editing_state(cx); - this.update_entries(cx); + this.update_entries(false, cx); cx.notify(); } }) @@ -358,7 +358,7 @@ impl CollabPanel { list_state, }; - this.update_entries(cx); + this.update_entries(false, cx); // Update the dock position when the setting changes. let mut old_dock_position = this.position(cx); @@ -376,13 +376,18 @@ impl CollabPanel { let active_call = ActiveCall::global(cx); this.subscriptions - .push(cx.observe(&this.user_store, |this, _, cx| this.update_entries(cx))); - this.subscriptions - .push(cx.observe(&this.channel_store, |this, _, cx| this.update_entries(cx))); + .push(cx.observe(&this.user_store, |this, _, cx| { + this.update_entries(false, cx) + })); this.subscriptions - .push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx))); + .push(cx.observe(&this.channel_store, |this, _, cx| { + this.update_entries(false, cx) + })); this.subscriptions - .push(cx.observe_global::(move |this, cx| this.update_entries(cx))); + .push(cx.observe(&active_call, |this, _, cx| this.update_entries(false, cx))); + this.subscriptions.push( + cx.observe_global::(move |this, cx| this.update_entries(false, cx)), + ); this }) @@ -434,7 +439,7 @@ impl CollabPanel { ); } - fn update_entries(&mut self, cx: &mut ViewContext) { + fn update_entries(&mut self, select_editor: bool, cx: &mut ViewContext) { let channel_store = self.channel_store.read(cx); let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); @@ -743,14 +748,23 @@ impl CollabPanel { } } - if let Some(prev_selected_entry) = prev_selected_entry { - self.selection.take(); + if select_editor { for (ix, entry) in self.entries.iter().enumerate() { - if *entry == prev_selected_entry { + if matches!(*entry, ListEntry::ChannelEditor { .. }) { self.selection = Some(ix); break; } } + } else { + if let Some(prev_selected_entry) = prev_selected_entry { + self.selection.take(); + for (ix, entry) in self.entries.iter().enumerate() { + if *entry == prev_selected_entry { + self.selection = Some(ix); + break; + } + } + } } let old_scroll_top = self.list_state.logical_scroll_top(); @@ -1643,7 +1657,7 @@ impl CollabPanel { }); } - self.update_entries(cx); + self.update_entries(false, cx); } fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { @@ -1724,17 +1738,28 @@ impl CollabPanel { ListEntry::Channel(channel) => { self.join_channel(channel.id, cx); } + ListEntry::ChannelEditor { .. } => { + self.confirm_channel_edit(cx); + } _ => {} } } - } else if let Some((editing_state, channel_name)) = self.take_editing_state(cx) { + } else { + self.confirm_channel_edit(cx); + } + } + + fn confirm_channel_edit(&mut self, cx: &mut ViewContext<'_, '_, CollabPanel>) { + if let Some((editing_state, channel_name)) = self.take_editing_state(cx) { let create_channel = self.channel_store.update(cx, |channel_store, _| { channel_store.create_channel(&channel_name, editing_state.parent_id) }); + self.update_entries(false, cx); + cx.foreground() .spawn(async move { - create_channel.await.ok(); + create_channel.await.log_err(); }) .detach(); } @@ -1746,7 +1771,7 @@ impl CollabPanel { } else { self.collapsed_sections.push(section); } - self.update_entries(cx); + self.update_entries(false, cx); } fn leave_call(cx: &mut ViewContext) { @@ -1771,7 +1796,7 @@ impl CollabPanel { fn new_root_channel(&mut self, cx: &mut ViewContext) { self.channel_editing_state = Some(ChannelEditingState { parent_id: None }); - self.update_entries(cx); + self.update_entries(true, cx); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } @@ -1780,7 +1805,7 @@ impl CollabPanel { self.channel_editing_state = Some(ChannelEditingState { parent_id: Some(action.channel_id), }); - self.update_entries(cx); + self.update_entries(true, cx); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } From beffe6f6a9c1bcef9565cc7bbe3eb8eb871c94b0 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 9 Aug 2023 12:44:34 -0400 Subject: [PATCH 059/105] WIP BROKEN --- crates/collab_ui/src/collab_panel.rs | 21 +++++++++++++++------ crates/theme/src/theme.rs | 6 +++--- styles/src/style_tree/collab_panel.ts | 15 ++------------- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b3b43dbabec735da5e7e338aab3629a4fc86d0f1..c93431362139b59da1c10039f0755bf5af8f7291 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1103,9 +1103,12 @@ impl CollabPanel { enum AddContact {} let button = match section { Section::ActiveCall => Some( - MouseEventHandler::::new(0, cx, |_, _| { + MouseEventHandler::::new(0, cx, |state, _| { render_icon_button( - theme.collab_panel.leave_call_button.in_state(is_selected), + theme + .collab_panel + .leave_call_button + .style_for(is_selected, state), "icons/radix/exit.svg", ) }) @@ -1122,9 +1125,12 @@ impl CollabPanel { ), ), Section::Contacts => Some( - MouseEventHandler::::new(0, cx, |_, _| { + MouseEventHandler::::new(0, cx, |state, _| { render_icon_button( - theme.collab_panel.add_contact_button.in_state(is_selected), + theme + .collab_panel + .add_contact_button + .style_for(is_selected, state), "icons/plus_16.svg", ) }) @@ -1141,9 +1147,12 @@ impl CollabPanel { ), ), Section::Channels => Some( - MouseEventHandler::::new(0, cx, |_, _| { + MouseEventHandler::::new(0, cx, |state, _| { render_icon_button( - theme.collab_panel.add_contact_button.in_state(is_selected), + theme + .collab_panel + .add_contact_button + .style_for(is_selected, state), "icons/plus_16.svg", ) }) diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index cf8da6233a4e4950d106d637275b3e59f50ef436..1756f91fb8e4cc96ec22d6f80a90b67b9c7505f2 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -226,9 +226,9 @@ pub struct CollabPanel { pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, - pub leave_call_button: Toggleable, - pub add_contact_button: Toggleable, - pub add_channel_button: Toggleable, + pub leave_call_button: Toggleable>, + pub add_contact_button: Toggleable>, + pub add_channel_button: Toggleable>, pub header_row: ContainedText, pub subheader_row: Toggleable>, pub leave_call: Interactive, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index a859f6d6700a772a2ba6f51311eacba9e6b2d6f1..fd6e75d9ec0b120d02501245fc172e10d30f7882 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -8,6 +8,7 @@ import { import { interactive, toggleable } from "../element" import { useTheme } from "../theme" import channel_modal from "./channel_modal" +import { icon_button, toggleable_icon_button } from "../component/icon_button" export default function contacts_panel(): any { @@ -51,19 +52,7 @@ export default function contacts_panel(): any { }, } - const headerButton = toggleable({ - base: { - color: foreground(layer, "on"), - button_width: 28, - icon_width: 16, - }, - state: { - active: { - background: background(layer, "active"), - corner_radius: 8, - } - } - }) + const headerButton = toggleable_icon_button(theme, {}) return { channel_modal: channel_modal(), From 498d043a0af2829431beee59683d2316d47108a0 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 9 Aug 2023 10:23:52 -0700 Subject: [PATCH 060/105] Avoid leak of channel store Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index ec945ce0367c1da14543f41b4fe9733ef1fec90d..8fb005a26289072871b684df9268ede85333b461 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -58,16 +58,20 @@ impl ChannelStore { client.add_message_handler(cx.handle(), Self::handle_update_channels); let mut current_user = user_store.read(cx).watch_current_user(); - let maintain_user = cx.spawn(|this, mut cx| async move { + let maintain_user = cx.spawn_weak(|this, mut cx| async move { while let Some(current_user) = current_user.next().await { if current_user.is_none() { - this.update(&mut cx, |this, cx| { - this.channels.clear(); - this.channel_invitations.clear(); - this.channel_participants.clear(); - this.outgoing_invites.clear(); - cx.notify(); - }); + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + this.channels.clear(); + this.channel_invitations.clear(); + this.channel_participants.clear(); + this.outgoing_invites.clear(); + cx.notify(); + }); + } else { + break; + } } } }); From 778fd6b0a95e6a44a026d1d0bc3f217170e11c67 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 9 Aug 2023 10:36:27 -0700 Subject: [PATCH 061/105] Represent channel relationships using paths table Co-authored-by: Mikayla --- .../20221109000000_test_schema.sql | 8 +- .../20230727150500_add_channels.sql | 8 +- crates/collab/src/db.rs | 135 +++++++++--------- .../db/{channel_parent.rs => channel_path.rs} | 7 +- 4 files changed, 80 insertions(+), 78 deletions(-) rename crates/collab/src/db/{channel_parent.rs => channel_path.rs} (69%) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 6703f98df26acb9f3755c6667cef0c374a12bcfb..3dceaecef4e15a3fcbc221102110ee441b876832 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -192,11 +192,11 @@ CREATE TABLE "channels" ( "created_at" TIMESTAMP NOT NULL DEFAULT now ); -CREATE TABLE "channel_parents" ( - "child_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "parent_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - PRIMARY KEY(child_id, parent_id) +CREATE TABLE "channel_paths" ( + "id_path" TEXT NOT NULL PRIMARY KEY, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE ); +CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id"); CREATE TABLE "channel_members" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql index 2d94cb6d9769f9165382ede3074bd58e90f313a6..df981838bf72d7ef7392ed6f4e302ffdc57631db 100644 --- a/crates/collab/migrations/20230727150500_add_channels.sql +++ b/crates/collab/migrations/20230727150500_add_channels.sql @@ -10,11 +10,11 @@ CREATE TABLE "channels" ( "created_at" TIMESTAMP NOT NULL DEFAULT now() ); -CREATE TABLE "channel_parents" ( - "child_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - "parent_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, - PRIMARY KEY(child_id, parent_id) +CREATE TABLE "channel_paths" ( + "id_path" VARCHAR NOT NULL PRIMARY KEY, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE ); +CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id"); CREATE TABLE "channel_members" ( "id" SERIAL PRIMARY KEY, diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index ed5e7e8e3d5a4ba0e06bf2847bc6877ec97f60bf..d830938497dd517e8f9ee92ffeb907df4fad9563 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,7 +1,7 @@ mod access_token; mod channel; mod channel_member; -mod channel_parent; +mod channel_path; mod contact; mod follower; mod language_server; @@ -3169,12 +3169,34 @@ impl Database { .insert(&*tx) .await?; + let channel_paths_stmt; if let Some(parent) = parent { - channel_parent::ActiveModel { - child_id: ActiveValue::Set(channel.id), - parent_id: ActiveValue::Set(parent), - } - .insert(&*tx) + let sql = r#" + INSERT INTO channel_paths + (id_path, channel_id) + SELECT + id_path || $1 || '/', $2 + FROM + channel_paths + WHERE + channel_id = $3 + "#; + channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [ + channel.id.to_proto().into(), + channel.id.to_proto().into(), + parent.to_proto().into(), + ], + ); + tx.execute(channel_paths_stmt).await?; + } else { + channel_path::Entity::insert(channel_path::ActiveModel { + channel_id: ActiveValue::Set(channel.id), + id_path: ActiveValue::Set(format!("/{}/", channel.id)), + }) + .exec(&*tx) .await?; } @@ -3213,9 +3235,9 @@ impl Database { // Don't remove descendant channels that have additional parents. let mut channels_to_remove = self.get_channel_descendants([channel_id], &*tx).await?; { - let mut channels_to_keep = channel_parent::Entity::find() + let mut channels_to_keep = channel_path::Entity::find() .filter( - channel_parent::Column::ChildId + channel_path::Column::ChannelId .is_in( channels_to_remove .keys() @@ -3223,15 +3245,15 @@ impl Database { .filter(|&id| id != channel_id), ) .and( - channel_parent::Column::ParentId - .is_not_in(channels_to_remove.keys().copied()), + channel_path::Column::IdPath + .not_like(&format!("%/{}/%", channel_id)), ), ) .stream(&*tx) .await?; while let Some(row) = channels_to_keep.next().await { let row = row?; - channels_to_remove.remove(&row.child_id); + channels_to_remove.remove(&row.channel_id); } } @@ -3631,40 +3653,21 @@ impl Database { channel_id: ChannelId, tx: &DatabaseTransaction, ) -> Result> { - let sql = format!( - r#" - WITH RECURSIVE channel_tree(child_id, parent_id) AS ( - SELECT CAST(NULL as INTEGER) as child_id, root_ids.column1 as parent_id - FROM (VALUES ({})) as root_ids - UNION - SELECT channel_parents.child_id, channel_parents.parent_id - FROM channel_parents, channel_tree - WHERE channel_parents.child_id = channel_tree.parent_id - ) - SELECT DISTINCT channel_tree.parent_id - FROM channel_tree - "#, - channel_id - ); - - #[derive(FromQueryResult, Debug, PartialEq)] - pub struct ChannelParent { - pub parent_id: ChannelId, - } - - let stmt = Statement::from_string(self.pool.get_database_backend(), sql); - - let mut channel_ids_stream = channel_parent::Entity::find() - .from_raw_sql(stmt) - .into_model::() - .stream(&*tx) + let paths = channel_path::Entity::find() + .filter(channel_path::Column::ChannelId.eq(channel_id)) + .all(tx) .await?; - - let mut channel_ids = vec![]; - while let Some(channel_id) = channel_ids_stream.next().await { - channel_ids.push(channel_id?.parent_id); + let mut channel_ids = Vec::new(); + for path in paths { + for id in path.id_path.trim_matches('/').split('/') { + if let Ok(id) = id.parse() { + let id = ChannelId::from_proto(id); + if let Err(ix) = channel_ids.binary_search(&id) { + channel_ids.insert(ix, id); + } + } + } } - Ok(channel_ids) } @@ -3687,38 +3690,38 @@ impl Database { let sql = format!( r#" - WITH RECURSIVE channel_tree(child_id, parent_id) AS ( - SELECT root_ids.column1 as child_id, CAST(NULL as INTEGER) as parent_id - FROM (VALUES {values}) as root_ids - UNION - SELECT channel_parents.child_id, channel_parents.parent_id - FROM channel_parents, channel_tree - WHERE channel_parents.parent_id = channel_tree.child_id - ) - SELECT channel_tree.child_id, channel_tree.parent_id - FROM channel_tree - ORDER BY child_id, parent_id IS NOT NULL - "#, + SELECT + descendant_paths.* + FROM + channel_paths parent_paths, channel_paths descendant_paths + WHERE + parent_paths.channel_id IN ({values}) AND + descendant_paths.id_path LIKE (parent_paths.id_path || '%') + "# ); - #[derive(FromQueryResult, Debug, PartialEq)] - pub struct ChannelParent { - pub child_id: ChannelId, - pub parent_id: Option, - } - let stmt = Statement::from_string(self.pool.get_database_backend(), sql); let mut parents_by_child_id = HashMap::default(); - let mut parents = channel_parent::Entity::find() + let mut paths = channel_path::Entity::find() .from_raw_sql(stmt) - .into_model::() .stream(tx) .await?; - while let Some(parent) = parents.next().await { - let parent = parent?; - parents_by_child_id.insert(parent.child_id, parent.parent_id); + while let Some(path) = paths.next().await { + let path = path?; + let ids = path.id_path.trim_matches('/').split('/'); + let mut parent_id = None; + for id in ids { + if let Ok(id) = id.parse() { + let id = ChannelId::from_proto(id); + if id == path.channel_id { + break; + } + parent_id = Some(id); + } + } + parents_by_child_id.insert(path.channel_id, parent_id); } Ok(parents_by_child_id) diff --git a/crates/collab/src/db/channel_parent.rs b/crates/collab/src/db/channel_path.rs similarity index 69% rename from crates/collab/src/db/channel_parent.rs rename to crates/collab/src/db/channel_path.rs index b0072155a3ce666f26124cc0372657d7c9512d85..08ecbddb56ec49cb0ab2c5d24ddec2e2e2bff129 100644 --- a/crates/collab/src/db/channel_parent.rs +++ b/crates/collab/src/db/channel_path.rs @@ -2,12 +2,11 @@ use super::ChannelId; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] -#[sea_orm(table_name = "channel_parents")] +#[sea_orm(table_name = "channel_paths")] pub struct Model { #[sea_orm(primary_key)] - pub child_id: ChannelId, - #[sea_orm(primary_key)] - pub parent_id: ChannelId, + pub id_path: String, + pub channel_id: ChannelId, } impl ActiveModelBehavior for ActiveModel {} From eed49a88bd9933bf61c100f3da46a0abe0285e0c Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 9 Aug 2023 11:04:09 -0700 Subject: [PATCH 062/105] Fix bad merge --- crates/collab/src/tests.rs | 49 ++------------------------------------ 1 file changed, 2 insertions(+), 47 deletions(-) diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index c669e1da40364c5f94f16d1adc204c419ef4f137..31d7b629f8df9e0cad2f2c9aa41928fb7f89f210 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -13,10 +13,7 @@ use client::{ use collections::{HashMap, HashSet}; use fs::FakeFs; use futures::{channel::oneshot, StreamExt as _}; -use gpui::{ - elements::*, executor::Deterministic, AnyElement, Entity, ModelHandle, Task, TestAppContext, - View, ViewContext, ViewHandle, WeakViewHandle, -}; +use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle}; use language::LanguageRegistry; use parking_lot::Mutex; use project::{Project, WorktreeId}; @@ -541,50 +538,8 @@ impl TestClient { &self, project: &ModelHandle, cx: &mut TestAppContext, - // <<<<<<< HEAD - // ) -> ViewHandle { - // struct WorkspaceContainer { - // workspace: Option>, - // } - - // impl Entity for WorkspaceContainer { - // type Event = (); - // } - - // impl View for WorkspaceContainer { - // fn ui_name() -> &'static str { - // "WorkspaceContainer" - // } - - // fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - // if let Some(workspace) = self - // .workspace - // .as_ref() - // .and_then(|workspace| workspace.upgrade(cx)) - // { - // ChildView::new(&workspace, cx).into_any() - // } else { - // Empty::new().into_any() - // } - // } - // } - - // // We use a workspace container so that we don't need to remove the window in order to - // // drop the workspace and we can use a ViewHandle instead. - // let window = cx.add_window(|_| WorkspaceContainer { workspace: None }); - // let container = window.root(cx); - // let workspace = window.add_view(cx, |cx| { - // Workspace::new(0, project.clone(), self.app_state.clone(), cx) - // }); - // container.update(cx, |container, cx| { - // container.workspace = Some(workspace.downgrade()); - // cx.notify(); - // }); - // workspace - // ======= ) -> WindowHandle { - cx.add_window(|cx| Workspace::test_new(project.clone(), cx)) - // >>>>>>> main + cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx)) } } From a3623ec2b84ea09f7901faf1d52f599c29884618 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 9 Aug 2023 12:20:48 -0700 Subject: [PATCH 063/105] Add renames co-authored-by: max --- crates/client/src/channel_store.rs | 51 +++++--- crates/client/src/channel_store_tests.rs | 6 +- crates/collab/src/db.rs | 49 ++++++-- crates/collab/src/db/tests.rs | 75 +++++++++--- crates/collab/src/rpc.rs | 58 ++++++--- crates/collab/src/tests/channel_tests.rs | 78 ++++++++++--- crates/collab_ui/src/collab_panel.rs | 142 +++++++++++++++++------ crates/rpc/proto/zed.proto | 15 ++- crates/rpc/src/proto.rs | 2 + 9 files changed, 356 insertions(+), 120 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 8fb005a26289072871b684df9268ede85333b461..b9aa4268cd1ecb0ea08c3461398e8b87107e9555 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -16,6 +16,7 @@ pub struct ChannelStore { channels: Vec>, channel_invitations: Vec>, channel_participants: HashMap>>, + channels_with_admin_privileges: HashSet, outgoing_invites: HashSet<(ChannelId, UserId)>, client: Arc, user_store: ModelHandle, @@ -28,7 +29,6 @@ pub struct Channel { pub id: ChannelId, pub name: String, pub parent_id: Option, - pub user_is_admin: bool, pub depth: usize, } @@ -79,6 +79,7 @@ impl ChannelStore { channels: vec![], channel_invitations: vec![], channel_participants: Default::default(), + channels_with_admin_privileges: Default::default(), outgoing_invites: Default::default(), client, user_store, @@ -100,17 +101,18 @@ impl ChannelStore { } pub fn is_user_admin(&self, mut channel_id: ChannelId) -> bool { - while let Some(channel) = self.channel_for_id(channel_id) { - if channel.user_is_admin { + loop { + if self.channels_with_admin_privileges.contains(&channel_id) { return true; } - if let Some(parent_id) = channel.parent_id { - channel_id = parent_id; - } else { - break; + if let Some(channel) = self.channel_for_id(channel_id) { + if let Some(parent_id) = channel.parent_id { + channel_id = parent_id; + continue; + } } + return false; } - false } pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc] { @@ -228,6 +230,22 @@ impl ChannelStore { }) } + pub fn rename( + &mut self, + channel_id: ChannelId, + new_name: &str, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + let name = new_name.to_string(); + cx.spawn(|_this, _cx| async move { + client + .request(proto::RenameChannel { channel_id, name }) + .await?; + Ok(()) + }) + } + pub fn respond_to_channel_invite( &mut self, channel_id: ChannelId, @@ -315,6 +333,8 @@ impl ChannelStore { .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); self.channel_participants .retain(|channel_id, _| !payload.remove_channels.contains(channel_id)); + self.channels_with_admin_privileges + .retain(|channel_id| !payload.remove_channels.contains(channel_id)); for channel in payload.channel_invitations { if let Some(existing_channel) = self @@ -324,7 +344,6 @@ impl ChannelStore { { let existing_channel = Arc::make_mut(existing_channel); existing_channel.name = channel.name; - existing_channel.user_is_admin = channel.user_is_admin; continue; } @@ -333,7 +352,6 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, - user_is_admin: false, parent_id: None, depth: 0, }), @@ -344,7 +362,6 @@ impl ChannelStore { if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { let existing_channel = Arc::make_mut(existing_channel); existing_channel.name = channel.name; - existing_channel.user_is_admin = channel.user_is_admin; continue; } @@ -357,7 +374,6 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, - user_is_admin: channel.user_is_admin, parent_id: Some(parent_id), depth, }), @@ -369,7 +385,6 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, - user_is_admin: channel.user_is_admin, parent_id: None, depth: 0, }), @@ -377,6 +392,16 @@ impl ChannelStore { } } + for permission in payload.channel_permissions { + if permission.is_admin { + self.channels_with_admin_privileges + .insert(permission.channel_id); + } else { + self.channels_with_admin_privileges + .remove(&permission.channel_id); + } + } + let mut all_user_ids = Vec::new(); let channel_participants = payload.channel_participants; for entry in &channel_participants { diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs index 69d5fed70d5358b327ca82edd8755e142b3e34e9..4ee54d3eca5a828ca2e82cae4317fbd38de61964 100644 --- a/crates/client/src/channel_store_tests.rs +++ b/crates/client/src/channel_store_tests.rs @@ -18,13 +18,11 @@ fn test_update_channels(cx: &mut AppContext) { id: 1, name: "b".to_string(), parent_id: None, - user_is_admin: true, }, proto::Channel { id: 2, name: "a".to_string(), parent_id: None, - user_is_admin: false, }, ], ..Default::default() @@ -49,13 +47,11 @@ fn test_update_channels(cx: &mut AppContext) { id: 3, name: "x".to_string(), parent_id: Some(1), - user_is_admin: false, }, proto::Channel { id: 4, name: "y".to_string(), parent_id: Some(2), - user_is_admin: false, }, ], ..Default::default() @@ -92,7 +88,7 @@ fn assert_channels( let actual = store .channels() .iter() - .map(|c| (c.depth, c.name.as_str(), c.user_is_admin)) + .map(|c| (c.depth, c.name.as_str(), store.is_user_admin(c.id))) .collect::>(); assert_eq!(actual, expected_channels); }); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index d830938497dd517e8f9ee92ffeb907df4fad9563..8faea0e40265ef2b63a5b3bb3deaacb292a591d8 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3155,7 +3155,7 @@ impl Database { live_kit_room: &str, creator_id: UserId, ) -> Result { - let name = name.trim().trim_start_matches('#'); + let name = Self::sanitize_channel_name(name)?; self.transaction(move |tx| async move { if let Some(parent) = parent { self.check_user_is_channel_admin(parent, creator_id, &*tx) @@ -3303,6 +3303,39 @@ impl Database { .await } + fn sanitize_channel_name(name: &str) -> Result<&str> { + let new_name = name.trim().trim_start_matches('#'); + if new_name == "" { + Err(anyhow!("channel name can't be blank"))?; + } + Ok(new_name) + } + + pub async fn rename_channel( + &self, + channel_id: ChannelId, + user_id: UserId, + new_name: &str, + ) -> Result { + self.transaction(move |tx| async move { + let new_name = Self::sanitize_channel_name(new_name)?.to_string(); + + self.check_user_is_channel_admin(channel_id, user_id, &*tx) + .await?; + + channel::ActiveModel { + id: ActiveValue::Unchanged(channel_id), + name: ActiveValue::Set(new_name.clone()), + ..Default::default() + } + .update(&*tx) + .await?; + + Ok(new_name) + }) + .await + } + pub async fn respond_to_channel_invite( &self, channel_id: ChannelId, @@ -3400,7 +3433,6 @@ impl Database { .map(|channel| Channel { id: channel.id, name: channel.name, - user_is_admin: false, parent_id: None, }) .collect(); @@ -3426,10 +3458,6 @@ impl Database { .all(&*tx) .await?; - let admin_channel_ids = channel_memberships - .iter() - .filter_map(|m| m.admin.then_some(m.channel_id)) - .collect::>(); let parents_by_child_id = self .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx) .await?; @@ -3445,7 +3473,6 @@ impl Database { channels.push(Channel { id: row.id, name: row.name, - user_is_admin: admin_channel_ids.contains(&row.id), parent_id: parents_by_child_id.get(&row.id).copied().flatten(), }); } @@ -3758,15 +3785,14 @@ impl Database { .one(&*tx) .await?; - let (user_is_admin, is_accepted) = channel_membership - .map(|membership| (membership.admin, membership.accepted)) - .unwrap_or((false, false)); + let is_accepted = channel_membership + .map(|membership| membership.accepted) + .unwrap_or(false); Ok(Some(( Channel { id: channel.id, name: channel.name, - user_is_admin, parent_id: None, }, is_accepted, @@ -4043,7 +4069,6 @@ pub struct NewUserResult { pub struct Channel { pub id: ChannelId, pub name: String, - pub user_is_admin: bool, pub parent_id: Option, } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index cdcde3332c54b7dfeda2fa6082b5a6c016d000c1..a659f3d16439b2bc82966598375e4d5c56156d08 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -962,43 +962,36 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { id: zed_id, name: "zed".to_string(), parent_id: None, - user_is_admin: true, }, Channel { id: crdb_id, name: "crdb".to_string(), parent_id: Some(zed_id), - user_is_admin: true, }, Channel { id: livestreaming_id, name: "livestreaming".to_string(), parent_id: Some(zed_id), - user_is_admin: true, }, Channel { id: replace_id, name: "replace".to_string(), parent_id: Some(zed_id), - user_is_admin: true, }, Channel { id: rust_id, name: "rust".to_string(), parent_id: None, - user_is_admin: true, }, Channel { id: cargo_id, name: "cargo".to_string(), parent_id: Some(rust_id), - user_is_admin: true, }, Channel { id: cargo_ra_id, name: "cargo-ra".to_string(), parent_id: Some(cargo_id), - user_is_admin: true, } ] ); @@ -1011,25 +1004,21 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { id: zed_id, name: "zed".to_string(), parent_id: None, - user_is_admin: false, }, Channel { id: crdb_id, name: "crdb".to_string(), parent_id: Some(zed_id), - user_is_admin: false, }, Channel { id: livestreaming_id, name: "livestreaming".to_string(), parent_id: Some(zed_id), - user_is_admin: false, }, Channel { id: replace_id, name: "replace".to_string(), parent_id: Some(zed_id), - user_is_admin: false, }, ] ); @@ -1048,25 +1037,21 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { id: zed_id, name: "zed".to_string(), parent_id: None, - user_is_admin: true, }, Channel { id: crdb_id, name: "crdb".to_string(), parent_id: Some(zed_id), - user_is_admin: false, }, Channel { id: livestreaming_id, name: "livestreaming".to_string(), parent_id: Some(zed_id), - user_is_admin: false, }, Channel { id: replace_id, name: "replace".to_string(), parent_id: Some(zed_id), - user_is_admin: false, }, ] ); @@ -1296,6 +1281,66 @@ test_both_dbs!( } ); +test_both_dbs!( + test_channel_renames_postgres, + test_channel_renames_sqlite, + db, + { + db.create_server("test").await.unwrap(); + + let user_1 = db + .create_user( + "user1@example.com", + false, + NewUserParams { + github_login: "user1".into(), + github_user_id: 5, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let user_2 = db + .create_user( + "user2@example.com", + false, + NewUserParams { + github_login: "user2".into(), + github_user_id: 6, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let zed_id = db.create_root_channel("zed", "1", user_1).await.unwrap(); + + db.rename_channel(zed_id, user_1, "#zed-archive") + .await + .unwrap(); + + let zed_archive_id = zed_id; + + let (channel, _) = db + .get_channel(zed_archive_id, user_1) + .await + .unwrap() + .unwrap(); + assert_eq!(channel.name, "zed-archive"); + + let non_permissioned_rename = db + .rename_channel(zed_archive_id, user_2, "hacked-lol") + .await; + assert!(non_permissioned_rename.is_err()); + + let bad_name_rename = db.rename_channel(zed_id, user_1, "#").await; + assert!(bad_name_rename.is_err()) + } +); + #[gpui::test] async fn test_multiple_signup_overwrite() { let test_db = TestDb::postgres(build_background_executor()); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index a24db6be81f755641c244de8bff3444c2fbbb06e..0f52c8c03a24e0b98b21c29a0677bbe684b69223 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -247,6 +247,7 @@ impl Server { .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) .add_request_handler(set_channel_member_admin) + .add_request_handler(rename_channel) .add_request_handler(get_channel_members) .add_request_handler(respond_to_channel_invite) .add_request_handler(join_channel) @@ -2151,7 +2152,6 @@ async fn create_channel( id: id.to_proto(), name: request.name, parent_id: request.parent_id, - user_is_admin: false, }); let user_ids_to_notify = if let Some(parent_id) = parent_id { @@ -2165,7 +2165,10 @@ async fn create_channel( for connection_id in connection_pool.user_connection_ids(user_id) { let mut update = update.clone(); if user_id == session.user_id { - update.channels[0].user_is_admin = true; + update.channel_permissions.push(proto::ChannelPermission { + channel_id: id.to_proto(), + is_admin: true, + }); } session.peer.send(connection_id, update)?; } @@ -2224,7 +2227,6 @@ async fn invite_channel_member( id: channel.id.to_proto(), name: channel.name, parent_id: None, - user_is_admin: false, }); for connection_id in session .connection_pool() @@ -2283,18 +2285,9 @@ async fn set_channel_member_admin( let mut update = proto::UpdateChannels::default(); if has_accepted { - update.channels.push(proto::Channel { - id: channel.id.to_proto(), - name: channel.name, - parent_id: None, - user_is_admin: request.admin, - }); - } else { - update.channel_invitations.push(proto::Channel { - id: channel.id.to_proto(), - name: channel.name, - parent_id: None, - user_is_admin: request.admin, + update.channel_permissions.push(proto::ChannelPermission { + channel_id: channel.id.to_proto(), + is_admin: request.admin, }); } @@ -2310,6 +2303,38 @@ async fn set_channel_member_admin( Ok(()) } +async fn rename_channel( + request: proto::RenameChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let new_name = db + .rename_channel(channel_id, session.user_id, &request.name) + .await?; + + response.send(proto::Ack {})?; + + let mut update = proto::UpdateChannels::default(); + update.channels.push(proto::Channel { + id: request.channel_id, + name: new_name, + parent_id: None, + }); + + let member_ids = db.get_channel_members(channel_id).await?; + + let connection_pool = session.connection_pool().await; + for member_id in member_ids { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + + Ok(()) +} + async fn get_channel_members( request: proto::GetChannelMembers, response: Response, @@ -2345,7 +2370,6 @@ async fn respond_to_channel_invite( .extend(channels.into_iter().map(|channel| proto::Channel { id: channel.id.to_proto(), name: channel.name, - user_is_admin: channel.user_is_admin, parent_id: channel.parent_id.map(ChannelId::to_proto), })); update @@ -2505,7 +2529,6 @@ fn build_initial_channels_update( update.channels.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, - user_is_admin: channel.user_is_admin, parent_id: channel.parent_id.map(|id| id.to_proto()), }); } @@ -2523,7 +2546,6 @@ fn build_initial_channels_update( update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, - user_is_admin: false, parent_id: None, }); } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 9723b18394864454d42920de853556f2a7fd4860..b2e9cae08a28825b27501f5c1d76325986555c98 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -40,14 +40,12 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, depth: 0, }), Arc::new(Channel { id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: true, depth: 1, }) ] @@ -82,7 +80,6 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: false, depth: 0, })] ) @@ -131,14 +128,13 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: false, + depth: 0, }), Arc::new(Channel { id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: false, depth: 1, }) ] @@ -162,21 +158,18 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: false, depth: 0, }), Arc::new(Channel { id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: false, depth: 1, }), Arc::new(Channel { id: channel_c_id, name: "channel-c".to_string(), parent_id: Some(channel_b_id), - user_is_admin: false, depth: 2, }), ] @@ -204,21 +197,18 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, depth: 0, }), Arc::new(Channel { id: channel_b_id, name: "channel-b".to_string(), parent_id: Some(channel_a_id), - user_is_admin: false, depth: 1, }), Arc::new(Channel { id: channel_c_id, name: "channel-c".to_string(), parent_id: Some(channel_b_id), - user_is_admin: false, depth: 2, }), ] @@ -244,7 +234,7 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, + depth: 0, })] ) @@ -256,7 +246,7 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, + depth: 0, })] ) @@ -281,7 +271,6 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - user_is_admin: true, depth: 0, })] ) @@ -395,7 +384,6 @@ async fn test_channel_room( id: zed_id, name: "zed".to_string(), parent_id: None, - user_is_admin: false, depth: 0, })] ) @@ -617,7 +605,7 @@ async fn test_permissions_update_while_invited( id: rust_id, name: "rust".to_string(), parent_id: None, - user_is_admin: false, + depth: 0, })], ); @@ -643,7 +631,7 @@ async fn test_permissions_update_while_invited( id: rust_id, name: "rust".to_string(), parent_id: None, - user_is_admin: true, + depth: 0, })], ); @@ -651,3 +639,59 @@ async fn test_permissions_update_while_invited( assert_eq!(channels.channels(), &[],); }); } + +#[gpui::test] +async fn test_channel_rename( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let rust_id = server + .make_channel("rust", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .await; + + // Rename the channel + client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.rename(rust_id, "#rust-archive", cx) + }) + .await + .unwrap(); + + let rust_archive_id = rust_id; + deterministic.run_until_parked(); + + // Client A sees the channel with its new name. + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: rust_archive_id, + name: "rust-archive".to_string(), + parent_id: None, + + depth: 0, + })], + ); + }); + + // Client B sees the channel with its new name. + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: rust_archive_id, + name: "rust-archive".to_string(), + parent_id: None, + + depth: 0, + })], + ); + }); +} diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index dd2a0db24332e840d90839d95a7fb9c46d6a7433..7bf229062286724f00d87dc15ace69343a728ce8 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -64,11 +64,22 @@ struct ManageMembers { channel_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct RenameChannel { + channel_id: u64, +} + actions!(collab_panel, [ToggleFocus, Remove, Secondary]); impl_actions!( collab_panel, - [RemoveChannel, NewChannel, InviteMembers, ManageMembers] + [ + RemoveChannel, + NewChannel, + InviteMembers, + ManageMembers, + RenameChannel + ] ); const CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; @@ -83,16 +94,19 @@ pub fn init(_client: Arc, cx: &mut AppContext) { cx.add_action(CollabPanel::select_prev); cx.add_action(CollabPanel::confirm); cx.add_action(CollabPanel::remove); - cx.add_action(CollabPanel::remove_channel_action); + cx.add_action(CollabPanel::remove_selected_channel); cx.add_action(CollabPanel::show_inline_context_menu); cx.add_action(CollabPanel::new_subchannel); cx.add_action(CollabPanel::invite_members); cx.add_action(CollabPanel::manage_members); + cx.add_action(CollabPanel::rename_selected_channel); + cx.add_action(CollabPanel::rename_channel); } -#[derive(Debug, Default)] -pub struct ChannelEditingState { - parent_id: Option, +#[derive(Debug)] +pub enum ChannelEditingState { + Create { parent_id: Option }, + Rename { channel_id: u64 }, } pub struct CollabPanel { @@ -581,19 +595,32 @@ impl CollabPanel { executor.clone(), )); if let Some(state) = &self.channel_editing_state { - if state.parent_id.is_none() { + if matches!(state, ChannelEditingState::Create { parent_id: None }) { self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } } for mat in matches { let channel = &channels[mat.candidate_id]; - self.entries.push(ListEntry::Channel(channel.clone())); - if let Some(state) = &self.channel_editing_state { - if state.parent_id == Some(channel.id) { + + match &self.channel_editing_state { + Some(ChannelEditingState::Create { parent_id }) + if *parent_id == Some(channel.id) => + { + self.entries.push(ListEntry::Channel(channel.clone())); self.entries.push(ListEntry::ChannelEditor { depth: channel.depth + 1, }); } + Some(ChannelEditingState::Rename { channel_id }) + if *channel_id == channel.id => + { + self.entries.push(ListEntry::ChannelEditor { + depth: channel.depth + 1, + }); + } + _ => { + self.entries.push(ListEntry::Channel(channel.clone())); + } } } } @@ -1065,15 +1092,15 @@ impl CollabPanel { &mut self, cx: &mut ViewContext, ) -> Option<(ChannelEditingState, String)> { - let result = self - .channel_editing_state - .take() - .map(|state| (state, self.channel_name_editor.read(cx).text(cx))); - - self.channel_name_editor - .update(cx, |editor, cx| editor.set_text("", cx)); - - result + if let Some(state) = self.channel_editing_state.take() { + self.channel_name_editor.update(cx, |editor, cx| { + let name = editor.text(cx); + editor.set_text("", cx); + Some((state, name)) + }) + } else { + None + } } fn render_header( @@ -1646,6 +1673,7 @@ impl CollabPanel { ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), ContextMenuItem::action("Manage members", ManageMembers { channel_id }), ContextMenuItem::action("Invite members", InviteMembers { channel_id }), + ContextMenuItem::action("Rename Channel", RenameChannel { channel_id }), ], cx, ); @@ -1702,6 +1730,10 @@ impl CollabPanel { } fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + if self.confirm_channel_edit(cx) { + return; + } + if let Some(selection) = self.selection { if let Some(entry) = self.entries.get(selection) { match entry { @@ -1747,30 +1779,38 @@ impl CollabPanel { ListEntry::Channel(channel) => { self.join_channel(channel.id, cx); } - ListEntry::ChannelEditor { .. } => { - self.confirm_channel_edit(cx); - } _ => {} } } - } else { - self.confirm_channel_edit(cx); } } - fn confirm_channel_edit(&mut self, cx: &mut ViewContext<'_, '_, CollabPanel>) { + fn confirm_channel_edit(&mut self, cx: &mut ViewContext<'_, '_, CollabPanel>) -> bool { if let Some((editing_state, channel_name)) = self.take_editing_state(cx) { - let create_channel = self.channel_store.update(cx, |channel_store, _| { - channel_store.create_channel(&channel_name, editing_state.parent_id) - }); - + match editing_state { + ChannelEditingState::Create { parent_id } => { + let request = self.channel_store.update(cx, |channel_store, _| { + channel_store.create_channel(&channel_name, parent_id) + }); + cx.foreground() + .spawn(async move { + request.await?; + anyhow::Ok(()) + }) + .detach(); + } + ChannelEditingState::Rename { channel_id } => { + self.channel_store + .update(cx, |channel_store, cx| { + channel_store.rename(channel_id, &channel_name, cx) + }) + .detach(); + } + } self.update_entries(false, cx); - - cx.foreground() - .spawn(async move { - create_channel.await.log_err(); - }) - .detach(); + true + } else { + false } } @@ -1804,14 +1844,14 @@ impl CollabPanel { } fn new_root_channel(&mut self, cx: &mut ViewContext) { - self.channel_editing_state = Some(ChannelEditingState { parent_id: None }); + self.channel_editing_state = Some(ChannelEditingState::Create { parent_id: None }); self.update_entries(true, cx); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext) { - self.channel_editing_state = Some(ChannelEditingState { + self.channel_editing_state = Some(ChannelEditingState::Create { parent_id: Some(action.channel_id), }); self.update_entries(true, cx); @@ -1835,7 +1875,33 @@ impl CollabPanel { } } - fn rename(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) {} + fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) { + if let Some(channel) = self.selected_channel() { + self.rename_channel( + &RenameChannel { + channel_id: channel.id, + }, + cx, + ); + } + } + + fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext) { + if let Some(channel) = self + .channel_store + .read(cx) + .channel_for_id(action.channel_id) + { + self.channel_editing_state = Some(ChannelEditingState::Rename { + channel_id: action.channel_id, + }); + self.channel_name_editor.update(cx, |editor, cx| { + editor.set_text(channel.name.clone(), cx); + editor.select_all(&Default::default(), cx); + }); + self.update_entries(true, cx); + } + } fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext) { let Some(channel) = self.selected_channel() else { @@ -1887,7 +1953,7 @@ impl CollabPanel { .detach(); } - fn remove_channel_action(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { + fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { self.remove_channel(action.channel_id, cx) } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 8f187a87c6ee8b2c92af174ae6423dede91e2d40..13b4c60aad4f20fb60b0b15fa1146d6e1dc5316d 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -141,6 +141,7 @@ message Envelope { GetChannelMembers get_channel_members = 128; GetChannelMembersResponse get_channel_members_response = 129; SetChannelMemberAdmin set_channel_member_admin = 130; + RenameChannel rename_channel = 131; } } @@ -874,6 +875,12 @@ message UpdateChannels { repeated Channel channel_invitations = 3; repeated uint64 remove_channel_invitations = 4; repeated ChannelParticipants channel_participants = 5; + repeated ChannelPermission channel_permissions = 6; +} + +message ChannelPermission { + uint64 channel_id = 1; + bool is_admin = 2; } message ChannelParticipants { @@ -935,6 +942,11 @@ message SetChannelMemberAdmin { bool admin = 3; } +message RenameChannel { + uint64 channel_id = 1; + string name = 2; +} + message RespondToChannelInvite { uint64 channel_id = 1; bool accept = 2; @@ -1303,8 +1315,7 @@ message Nonce { message Channel { uint64 id = 1; string name = 2; - bool user_is_admin = 3; - optional uint64 parent_id = 4; + optional uint64 parent_id = 3; } message Contact { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index fac011f803317c7f436e14bd7e56c595347d3860..d3a30911314c19a0939781348e61908f1d8fec4e 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -217,6 +217,7 @@ messages!( (JoinChannel, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), + (RenameChannel, Foreground), (SetChannelMemberAdmin, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), @@ -304,6 +305,7 @@ request_messages!( (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), (RenameProjectEntry, ProjectEntryResponse), + (RenameChannel, Ack), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), (ShareProject, ShareProjectResponse), From 60e25d780a7c54ebbf353044634f71e9f73db63f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 9 Aug 2023 13:43:16 -0700 Subject: [PATCH 064/105] Send channel permissions to clients when they fetch their channels --- crates/client/src/channel_store_tests.rs | 9 ++++-- crates/collab/src/db.rs | 30 ++++++++++++------- crates/collab/src/db/tests.rs | 12 ++++---- crates/collab/src/rpc.rs | 38 +++++++++++++++++------- 4 files changed, 60 insertions(+), 29 deletions(-) diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs index 4ee54d3eca5a828ca2e82cae4317fbd38de61964..f74169eb2a57eec59ffe34e58371f8d952875334 100644 --- a/crates/client/src/channel_store_tests.rs +++ b/crates/client/src/channel_store_tests.rs @@ -1,6 +1,5 @@ -use util::http::FakeHttpClient; - use super::*; +use util::http::FakeHttpClient; #[gpui::test] fn test_update_channels(cx: &mut AppContext) { @@ -25,6 +24,10 @@ fn test_update_channels(cx: &mut AppContext) { parent_id: None, }, ], + channel_permissions: vec![proto::ChannelPermission { + channel_id: 1, + is_admin: true, + }], ..Default::default() }, cx, @@ -64,7 +67,7 @@ fn test_update_channels(cx: &mut AppContext) { (0, "a", false), (1, "y", false), (0, "b", true), - (1, "x", false), + (1, "x", true), ], cx, ); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 8faea0e40265ef2b63a5b3bb3deaacb292a591d8..b7718be1187e7e07308a520bdea381776a2d7fd7 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3442,10 +3442,7 @@ impl Database { .await } - pub async fn get_channels_for_user( - &self, - user_id: UserId, - ) -> Result<(Vec, HashMap>)> { + pub async fn get_channels_for_user(&self, user_id: UserId) -> Result { self.transaction(|tx| async move { let tx = tx; @@ -3462,6 +3459,11 @@ impl Database { .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx) .await?; + let channels_with_admin_privileges = channel_memberships + .iter() + .filter_map(|membership| membership.admin.then_some(membership.channel_id)) + .collect(); + let mut channels = Vec::with_capacity(parents_by_child_id.len()); { let mut rows = channel::Entity::find() @@ -3484,7 +3486,7 @@ impl Database { UserId, } - let mut participants_by_channel: HashMap> = HashMap::default(); + let mut channel_participants: HashMap> = HashMap::default(); { let mut rows = room_participant::Entity::find() .inner_join(room::Entity) @@ -3497,14 +3499,15 @@ impl Database { .await?; while let Some(row) = rows.next().await { let row: (ChannelId, UserId) = row?; - participants_by_channel - .entry(row.0) - .or_default() - .push(row.1) + channel_participants.entry(row.0).or_default().push(row.1) } } - Ok((channels, participants_by_channel)) + Ok(ChannelsForUser { + channels, + channel_participants, + channels_with_admin_privileges, + }) }) .await } @@ -4072,6 +4075,13 @@ pub struct Channel { pub parent_id: Option, } +#[derive(Debug, PartialEq)] +pub struct ChannelsForUser { + pub channels: Vec, + pub channel_participants: HashMap>, + pub channels_with_admin_privileges: HashSet, +} + fn random_invite_code() -> String { nanoid::nanoid!(16) } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index a659f3d16439b2bc82966598375e4d5c56156d08..2680d81aac8c9f3e4dce686b7274680556f4e388 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -954,9 +954,9 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { .await .unwrap(); - let (channels, _) = db.get_channels_for_user(a_id).await.unwrap(); + let result = db.get_channels_for_user(a_id).await.unwrap(); assert_eq!( - channels, + result.channels, vec![ Channel { id: zed_id, @@ -996,9 +996,9 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { ] ); - let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); + let result = db.get_channels_for_user(b_id).await.unwrap(); assert_eq!( - channels, + result.channels, vec![ Channel { id: zed_id, @@ -1029,9 +1029,9 @@ test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, { let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await; assert!(set_channel_admin.is_ok()); - let (channels, _) = db.get_channels_for_user(b_id).await.unwrap(); + let result = db.get_channels_for_user(b_id).await.unwrap(); assert_eq!( - channels, + result.channels, vec![ Channel { id: zed_id, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 0f52c8c03a24e0b98b21c29a0677bbe684b69223..07d343959fa45d04d9adbd01af7d5f05d6341830 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -529,7 +529,7 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, invite_code, (channels, channel_participants), channel_invites) = future::try_join4( + let (contacts, invite_code, channels_for_user, channel_invites) = future::try_join4( this.app_state.db.get_contacts(user_id), this.app_state.db.get_invite_code_for_user(user_id), this.app_state.db.get_channels_for_user(user_id), @@ -540,7 +540,11 @@ impl Server { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; - this.peer.send(connection_id, build_initial_channels_update(channels, channel_participants, channel_invites))?; + this.peer.send(connection_id, build_initial_channels_update( + channels_for_user.channels, + channels_for_user.channel_participants, + channel_invites + ))?; if let Some((code, count)) = invite_code { this.peer.send(connection_id, proto::UpdateInviteInfo { @@ -2364,22 +2368,36 @@ async fn respond_to_channel_invite( .remove_channel_invitations .push(channel_id.to_proto()); if request.accept { - let (channels, participants) = db.get_channels_for_user(session.user_id).await?; + let result = db.get_channels_for_user(session.user_id).await?; update .channels - .extend(channels.into_iter().map(|channel| proto::Channel { + .extend(result.channels.into_iter().map(|channel| proto::Channel { id: channel.id.to_proto(), name: channel.name, parent_id: channel.parent_id.map(ChannelId::to_proto), })); update .channel_participants - .extend(participants.into_iter().map(|(channel_id, user_ids)| { - proto::ChannelParticipants { - channel_id: channel_id.to_proto(), - participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(), - } - })); + .extend( + result + .channel_participants + .into_iter() + .map(|(channel_id, user_ids)| proto::ChannelParticipants { + channel_id: channel_id.to_proto(), + participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(), + }), + ); + update + .channel_permissions + .extend( + result + .channels_with_admin_privileges + .into_iter() + .map(|channel_id| proto::ChannelPermission { + channel_id: channel_id.to_proto(), + is_admin: true, + }), + ); } session.peer.send(session.connection_id, update)?; response.send(proto::Ack {})?; From ac1b2b18aaeb5cc979849afb91154c6bbf9f940e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 9 Aug 2023 14:40:47 -0700 Subject: [PATCH 065/105] Send user ids of channels of which they are admins on connecting Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 14 ++++---- crates/collab/src/rpc.rs | 24 ++++++++++---- crates/collab/src/tests/channel_tests.rs | 41 +++++++++++++++++++++--- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index b9aa4268cd1ecb0ea08c3461398e8b87107e9555..6325bc1a30ff60d525e855f9bc5b7abf59f40c93 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -1,3 +1,4 @@ +use crate::Status; use crate::{Client, Subscription, User, UserStore}; use anyhow::anyhow; use anyhow::Result; @@ -21,7 +22,7 @@ pub struct ChannelStore { client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, - _maintain_user: Task<()>, + _watch_connection_status: Task<()>, } #[derive(Clone, Debug, PartialEq)] @@ -57,15 +58,16 @@ impl ChannelStore { let rpc_subscription = client.add_message_handler(cx.handle(), Self::handle_update_channels); - let mut current_user = user_store.read(cx).watch_current_user(); - let maintain_user = cx.spawn_weak(|this, mut cx| async move { - while let Some(current_user) = current_user.next().await { - if current_user.is_none() { + let mut connection_status = client.status(); + let watch_connection_status = cx.spawn_weak(|this, mut cx| async move { + while let Some(status) = connection_status.next().await { + if matches!(status, Status::ConnectionLost | Status::SignedOut) { if let Some(this) = this.upgrade(&cx) { this.update(&mut cx, |this, cx| { this.channels.clear(); this.channel_invitations.clear(); this.channel_participants.clear(); + this.channels_with_admin_privileges.clear(); this.outgoing_invites.clear(); cx.notify(); }); @@ -84,7 +86,7 @@ impl ChannelStore { client, user_store, _rpc_subscription: rpc_subscription, - _maintain_user: maintain_user, + _watch_connection_status: watch_connection_status, } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 07d343959fa45d04d9adbd01af7d5f05d6341830..c2f0d31f9044ca63c0ad62c1ea0e942c42849af1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2,7 +2,7 @@ mod connection_pool; use crate::{ auth, - db::{self, ChannelId, Database, ProjectId, RoomId, ServerId, User, UserId}, + db::{self, ChannelId, ChannelsForUser, Database, ProjectId, RoomId, ServerId, User, UserId}, executor::Executor, AppState, Result, }; @@ -541,8 +541,7 @@ impl Server { pool.add_connection(connection_id, user_id, user.admin); this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_initial_channels_update( - channels_for_user.channels, - channels_for_user.channel_participants, + channels_for_user, channel_invites ))?; @@ -2537,13 +2536,12 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage { } fn build_initial_channels_update( - channels: Vec, - channel_participants: HashMap>, + channels: ChannelsForUser, channel_invites: Vec, ) -> proto::UpdateChannels { let mut update = proto::UpdateChannels::default(); - for channel in channels { + for channel in channels.channels { update.channels.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, @@ -2551,7 +2549,7 @@ fn build_initial_channels_update( }); } - for (channel_id, participants) in channel_participants { + for (channel_id, participants) in channels.channel_participants { update .channel_participants .push(proto::ChannelParticipants { @@ -2560,6 +2558,18 @@ fn build_initial_channels_update( }); } + update + .channel_permissions + .extend( + channels + .channels_with_admin_privileges + .into_iter() + .map(|id| proto::ChannelPermission { + channel_id: id.to_proto(), + is_admin: true, + }), + ); + for channel in channel_invites { update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index b2e9cae08a28825b27501f5c1d76325986555c98..63fab0d5f8773d2351715bb7e79e8dcf337673ee 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1,8 +1,11 @@ -use crate::tests::{room_participants, RoomParticipants, TestServer}; +use crate::{ + rpc::RECONNECT_TIMEOUT, + tests::{room_participants, RoomParticipants, TestServer}, +}; use call::ActiveCall; use client::{Channel, ChannelMembership, User}; use gpui::{executor::Deterministic, TestAppContext}; -use rpc::proto; +use rpc::{proto, RECEIVE_TIMEOUT}; use std::sync::Arc; #[gpui::test] @@ -49,7 +52,9 @@ async fn test_core_channels( depth: 1, }) ] - ) + ); + assert!(channels.is_user_admin(channel_a_id)); + assert!(channels.is_user_admin(channel_b_id)); }); client_b @@ -84,6 +89,7 @@ async fn test_core_channels( })] ) }); + let members = client_a .channel_store() .update(cx_a, |store, cx| { @@ -128,7 +134,6 @@ async fn test_core_channels( id: channel_a_id, name: "channel-a".to_string(), parent_id: None, - depth: 0, }), Arc::new(Channel { @@ -138,7 +143,9 @@ async fn test_core_channels( depth: 1, }) ] - ) + ); + assert!(!channels.is_user_admin(channel_a_id)); + assert!(!channels.is_user_admin(channel_b_id)); }); let channel_c_id = client_a @@ -280,6 +287,30 @@ async fn test_core_channels( client_b .channel_store() .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); + + // When disconnected, client A sees no channels. + server.forbid_connections(); + server.disconnect_client(client_a.peer_id().unwrap()); + deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!(channels.channels(), &[]); + assert!(!channels.is_user_admin(channel_a_id)); + }); + + server.allow_connections(); + deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_eq!( + channels.channels(), + &[Arc::new(Channel { + id: channel_a_id, + name: "channel-a".to_string(), + parent_id: None, + depth: 0, + })] + ); + assert!(channels.is_user_admin(channel_a_id)); + }); } fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u64]) { From 076b72cf2b122d8bd516766a89de1abf7f44b04a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 9 Aug 2023 15:11:30 -0700 Subject: [PATCH 066/105] Improve styling of collab panel --- styles/src/style_tree/collab_panel.ts | 70 +++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index fd6e75d9ec0b120d02501245fc172e10d30f7882..0979760b88f34cbe230d847c3b2017f0b8b00d2c 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -52,7 +52,61 @@ export default function contacts_panel(): any { }, } - const headerButton = toggleable_icon_button(theme, {}) + const headerButton = toggleable({ + state: { + inactive: interactive({ + base: { + corner_radius: 6, + padding: { + top: 2, + bottom: 2, + left: 4, + right: 4, + }, + icon_width: 14, + icon_height: 14, + button_width: 20, + button_height: 16, + color: foreground(layer, "on"), + }, + state: { + default: { + }, + hovered: { + background: background(layer, "base", "hovered"), + }, + clicked: { + background: background(layer, "base", "pressed"), + }, + }, + }), + active: interactive({ + base: { + corner_radius: 6, + padding: { + top: 2, + bottom: 2, + left: 4, + right: 4, + }, + icon_width: 14, + icon_height: 14, + button_width: 20, + button_height: 16, + color: foreground(layer, "on"), + }, + state: { + default: { + background: background(layer, "base", "active"), + }, + clicked: { + background: background(layer, "base", "active"), + }, + }, + }), + }, + }) + return { channel_modal: channel_modal(), @@ -154,9 +208,6 @@ export default function contacts_panel(): any { ...text(layer, "mono", "active", { size: "sm" }), background: background(layer, "active"), }, - hovered: { - background: background(layer, "hovered"), - }, clicked: { background: background(layer, "pressed"), }, @@ -196,23 +247,22 @@ export default function contacts_panel(): any { }, }, state: { - hovered: { - background: background(layer, "hovered"), - }, clicked: { background: background(layer, "pressed"), }, }, }), state: { + inactive: { + hovered: { + background: background(layer, "hovered"), + }, + }, active: { default: { ...text(layer, "mono", "active", { size: "sm" }), background: background(layer, "active"), }, - hovered: { - background: background(layer, "hovered"), - }, clicked: { background: background(layer, "pressed"), }, From b3447ada275b4005e6bab70242f827e3b3dc39ce Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 9 Aug 2023 17:11:52 -0700 Subject: [PATCH 067/105] Dial in the channel creating/renaming UI * Ensure channel list is in a consistent state with no flicker while the channel creation / rename request is outstanding. * Maintain selection properly when renaming and creating channels. * Style the channel name editor more consistently with the non-editable channel names. Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 62 +++++- crates/collab/src/rpc.rs | 28 +-- crates/collab/src/tests.rs | 4 +- crates/collab/src/tests/channel_tests.rs | 16 +- crates/collab_ui/src/collab_panel.rs | 228 ++++++++++++++++------- crates/rpc/proto/zed.proto | 18 +- crates/rpc/src/proto.rs | 6 +- styles/src/style_tree/collab_panel.ts | 2 +- 8 files changed, 257 insertions(+), 107 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 6325bc1a30ff60d525e855f9bc5b7abf59f40c93..206423579aa036624834b6752771c8bcac48e8b8 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -39,8 +39,13 @@ pub struct ChannelMembership { pub admin: bool, } +pub enum ChannelEvent { + ChannelCreated(ChannelId), + ChannelRenamed(ChannelId), +} + impl Entity for ChannelStore { - type Event = (); + type Event = ChannelEvent; } pub enum ChannelMemberStatus { @@ -127,15 +132,37 @@ impl ChannelStore { &self, name: &str, parent_id: Option, - ) -> impl Future> { + cx: &mut ModelContext, + ) -> Task> { let client = self.client.clone(); let name = name.trim_start_matches("#").to_owned(); - async move { - Ok(client + cx.spawn(|this, mut cx| async move { + let channel = client .request(proto::CreateChannel { name, parent_id }) .await? - .channel_id) - } + .channel + .ok_or_else(|| anyhow!("missing channel in response"))?; + + let channel_id = channel.id; + + this.update(&mut cx, |this, cx| { + this.update_channels( + proto::UpdateChannels { + channels: vec![channel], + ..Default::default() + }, + cx, + ); + + // This event is emitted because the collab panel wants to clear the pending edit state + // before this frame is rendered. But we can't guarantee that the collab panel's future + // will resolve before this flush_effects finishes. Synchronously emitting this event + // ensures that the collab panel will observe this creation before the frame completes + cx.emit(ChannelEvent::ChannelCreated(channel_id)); + }); + + Ok(channel_id) + }) } pub fn invite_member( @@ -240,10 +267,27 @@ impl ChannelStore { ) -> Task> { let client = self.client.clone(); let name = new_name.to_string(); - cx.spawn(|_this, _cx| async move { - client + cx.spawn(|this, mut cx| async move { + let channel = client .request(proto::RenameChannel { channel_id, name }) - .await?; + .await? + .channel + .ok_or_else(|| anyhow!("missing channel in response"))?; + this.update(&mut cx, |this, cx| { + this.update_channels( + proto::UpdateChannels { + channels: vec![channel], + ..Default::default() + }, + cx, + ); + + // This event is emitted because the collab panel wants to clear the pending edit state + // before this frame is rendered. But we can't guarantee that the collab panel's future + // will resolve before this flush_effects finishes. Synchronously emitting this event + // ensures that the collab panel will observe this creation before the frame complete + cx.emit(ChannelEvent::ChannelRenamed(channel_id)) + }); Ok(()) }) } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index c2f0d31f9044ca63c0ad62c1ea0e942c42849af1..f9f2d4a2e24109354fbf363e3acb11fb711f0d63 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2146,16 +2146,18 @@ async fn create_channel( .create_channel(&request.name, parent_id, &live_kit_room, session.user_id) .await?; - response.send(proto::CreateChannelResponse { - channel_id: id.to_proto(), - })?; - - let mut update = proto::UpdateChannels::default(); - update.channels.push(proto::Channel { + let channel = proto::Channel { id: id.to_proto(), name: request.name, parent_id: request.parent_id, - }); + }; + + response.send(proto::ChannelResponse { + channel: Some(channel.clone()), + })?; + + let mut update = proto::UpdateChannels::default(); + update.channels.push(channel); let user_ids_to_notify = if let Some(parent_id) = parent_id { db.get_channel_members(parent_id).await? @@ -2317,14 +2319,16 @@ async fn rename_channel( .rename_channel(channel_id, session.user_id, &request.name) .await?; - response.send(proto::Ack {})?; - - let mut update = proto::UpdateChannels::default(); - update.channels.push(proto::Channel { + let channel = proto::Channel { id: request.channel_id, name: new_name, parent_id: None, - }); + }; + response.send(proto::ChannelResponse { + channel: Some(channel.clone()), + })?; + let mut update = proto::UpdateChannels::default(); + update.channels.push(channel); let member_ids = db.get_channel_members(channel_id).await?; diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 31d7b629f8df9e0cad2f2c9aa41928fb7f89f210..46cbcb0213af963446158b096b5051a84f7ab94b 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -278,8 +278,8 @@ impl TestServer { let channel_id = admin_client .app_state .channel_store - .update(admin_cx, |channel_store, _| { - channel_store.create_channel(channel, None) + .update(admin_cx, |channel_store, cx| { + channel_store.create_channel(channel, None, cx) }) .await .unwrap(); diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 63fab0d5f8773d2351715bb7e79e8dcf337673ee..0dc6d478d14226f080a0a3ce5bb22dee1a3b35e0 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -21,15 +21,15 @@ async fn test_core_channels( let channel_a_id = client_a .channel_store() - .update(cx_a, |channel_store, _| { - channel_store.create_channel("channel-a", None) + .update(cx_a, |channel_store, cx| { + channel_store.create_channel("channel-a", None, cx) }) .await .unwrap(); let channel_b_id = client_a .channel_store() - .update(cx_a, |channel_store, _| { - channel_store.create_channel("channel-b", Some(channel_a_id)) + .update(cx_a, |channel_store, cx| { + channel_store.create_channel("channel-b", Some(channel_a_id), cx) }) .await .unwrap(); @@ -150,8 +150,8 @@ async fn test_core_channels( let channel_c_id = client_a .channel_store() - .update(cx_a, |channel_store, _| { - channel_store.create_channel("channel-c", Some(channel_b_id)) + .update(cx_a, |channel_store, cx| { + channel_store.create_channel("channel-c", Some(channel_b_id), cx) }) .await .unwrap(); @@ -351,8 +351,8 @@ async fn test_joining_channel_ancestor_member( let sub_id = client_a .channel_store() - .update(cx_a, |channel_store, _| { - channel_store.create_channel("sub_channel", Some(parent_id)) + .update(cx_a, |channel_store, cx| { + channel_store.create_channel("sub_channel", Some(parent_id), cx) }) .await .unwrap(); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 7bf229062286724f00d87dc15ace69343a728ce8..cb40d496b6d08812489689278d4815180083da9e 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -4,7 +4,9 @@ mod panel_settings; use anyhow::Result; use call::ActiveCall; -use client::{proto::PeerId, Channel, ChannelId, ChannelStore, Client, Contact, User, UserStore}; +use client::{ + proto::PeerId, Channel, ChannelEvent, ChannelId, ChannelStore, Client, Contact, User, UserStore, +}; use contact_finder::build_contact_finder; use context_menu::{ContextMenu, ContextMenuItem}; use db::kvp::KEY_VALUE_STORE; @@ -105,8 +107,23 @@ pub fn init(_client: Arc, cx: &mut AppContext) { #[derive(Debug)] pub enum ChannelEditingState { - Create { parent_id: Option }, - Rename { channel_id: u64 }, + Create { + parent_id: Option, + pending_name: Option, + }, + Rename { + channel_id: u64, + pending_name: Option, + }, +} + +impl ChannelEditingState { + fn pending_name(&self) -> Option<&str> { + match self { + ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(), + ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(), + } + } } pub struct CollabPanel { @@ -211,7 +228,7 @@ impl CollabPanel { if !query.is_empty() { this.selection.take(); } - this.update_entries(false, cx); + this.update_entries(true, cx); if !query.is_empty() { this.selection = this .entries @@ -233,6 +250,11 @@ impl CollabPanel { cx.subscribe(&channel_name_editor, |this, _, event, cx| { if let editor::Event::Blurred = event { + if let Some(state) = &this.channel_editing_state { + if state.pending_name().is_some() { + return; + } + } this.take_editing_state(cx); this.update_entries(false, cx); cx.notify(); @@ -391,17 +413,35 @@ impl CollabPanel { let active_call = ActiveCall::global(cx); this.subscriptions .push(cx.observe(&this.user_store, |this, _, cx| { - this.update_entries(false, cx) + this.update_entries(true, cx) })); this.subscriptions .push(cx.observe(&this.channel_store, |this, _, cx| { - this.update_entries(false, cx) + this.update_entries(true, cx) })); this.subscriptions - .push(cx.observe(&active_call, |this, _, cx| this.update_entries(false, cx))); + .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx))); this.subscriptions.push( - cx.observe_global::(move |this, cx| this.update_entries(false, cx)), + cx.observe_global::(move |this, cx| this.update_entries(true, cx)), ); + this.subscriptions.push(cx.subscribe( + &this.channel_store, + |this, _channel_store, e, cx| match e { + ChannelEvent::ChannelCreated(channel_id) + | ChannelEvent::ChannelRenamed(channel_id) => { + if this.take_editing_state(cx) { + this.update_entries(false, cx); + this.selection = this.entries.iter().position(|entry| { + if let ListEntry::Channel(channel) = entry { + channel.id == *channel_id + } else { + false + } + }); + } + } + }, + )); this }) @@ -453,7 +493,7 @@ impl CollabPanel { ); } - fn update_entries(&mut self, select_editor: bool, cx: &mut ViewContext) { + fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext) { let channel_store = self.channel_store.read(cx); let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); @@ -595,7 +635,13 @@ impl CollabPanel { executor.clone(), )); if let Some(state) = &self.channel_editing_state { - if matches!(state, ChannelEditingState::Create { parent_id: None }) { + if matches!( + state, + ChannelEditingState::Create { + parent_id: None, + .. + } + ) { self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } } @@ -603,7 +649,7 @@ impl CollabPanel { let channel = &channels[mat.candidate_id]; match &self.channel_editing_state { - Some(ChannelEditingState::Create { parent_id }) + Some(ChannelEditingState::Create { parent_id, .. }) if *parent_id == Some(channel.id) => { self.entries.push(ListEntry::Channel(channel.clone())); @@ -611,11 +657,11 @@ impl CollabPanel { depth: channel.depth + 1, }); } - Some(ChannelEditingState::Rename { channel_id }) + Some(ChannelEditingState::Rename { channel_id, .. }) if *channel_id == channel.id => { self.entries.push(ListEntry::ChannelEditor { - depth: channel.depth + 1, + depth: channel.depth, }); } _ => { @@ -775,14 +821,7 @@ impl CollabPanel { } } - if select_editor { - for (ix, entry) in self.entries.iter().enumerate() { - if matches!(*entry, ListEntry::ChannelEditor { .. }) { - self.selection = Some(ix); - break; - } - } - } else { + if select_same_item { if let Some(prev_selected_entry) = prev_selected_entry { self.selection.take(); for (ix, entry) in self.entries.iter().enumerate() { @@ -792,6 +831,14 @@ impl CollabPanel { } } } + } else { + self.selection = self.selection.and_then(|prev_selection| { + if self.entries.is_empty() { + None + } else { + Some(prev_selection.min(self.entries.len() - 1)) + } + }); } let old_scroll_top = self.list_state.logical_scroll_top(); @@ -1088,18 +1135,14 @@ impl CollabPanel { .into_any() } - fn take_editing_state( - &mut self, - cx: &mut ViewContext, - ) -> Option<(ChannelEditingState, String)> { - if let Some(state) = self.channel_editing_state.take() { + fn take_editing_state(&mut self, cx: &mut ViewContext) -> bool { + if let Some(_) = self.channel_editing_state.take() { self.channel_name_editor.update(cx, |editor, cx| { - let name = editor.text(cx); editor.set_text("", cx); - Some((state, name)) - }) + }); + true } else { - None + false } } @@ -1367,22 +1410,43 @@ impl CollabPanel { .left(), ) .with_child( - ChildView::new(&self.channel_name_editor, cx) + if let Some(pending_name) = self + .channel_editing_state + .as_ref() + .and_then(|state| state.pending_name()) + { + Label::new( + pending_name.to_string(), + theme.collab_panel.contact_username.text.clone(), + ) .contained() - .with_style(theme.collab_panel.channel_editor) - .flex(1.0, true), + .with_style(theme.collab_panel.contact_username.container) + .aligned() + .left() + .flex(1., true) + .into_any() + } else { + ChildView::new(&self.channel_name_editor, cx) + .aligned() + .left() + .contained() + .with_style(theme.collab_panel.channel_editor) + .flex(1.0, true) + .into_any() + }, ) .align_children_center() + .constrained() + .with_height(theme.collab_panel.row_height) .contained() + .with_style(gpui::elements::ContainerStyle { + background_color: Some(theme.editor.background), + ..*theme.collab_panel.contact_row.default_style() + }) .with_padding_left( theme.collab_panel.contact_row.default_style().padding.left + theme.collab_panel.channel_indent * depth as f32, ) - .contained() - .with_style(gpui::elements::ContainerStyle { - background_color: Some(theme.editor.background), - ..Default::default() - }) .into_any() } @@ -1684,7 +1748,7 @@ impl CollabPanel { } fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { - if self.take_editing_state(cx).is_some() { + if self.take_editing_state(cx) { cx.focus(&self.filter_editor); } else { self.filter_editor.update(cx, |editor, cx| { @@ -1785,29 +1849,47 @@ impl CollabPanel { } } - fn confirm_channel_edit(&mut self, cx: &mut ViewContext<'_, '_, CollabPanel>) -> bool { - if let Some((editing_state, channel_name)) = self.take_editing_state(cx) { + fn confirm_channel_edit(&mut self, cx: &mut ViewContext) -> bool { + if let Some(editing_state) = &mut self.channel_editing_state { match editing_state { - ChannelEditingState::Create { parent_id } => { - let request = self.channel_store.update(cx, |channel_store, _| { - channel_store.create_channel(&channel_name, parent_id) - }); - cx.foreground() - .spawn(async move { - request.await?; - anyhow::Ok(()) + ChannelEditingState::Create { + parent_id, + pending_name, + .. + } => { + if pending_name.is_some() { + return false; + } + let channel_name = self.channel_name_editor.read(cx).text(cx); + + *pending_name = Some(channel_name.clone()); + + self.channel_store + .update(cx, |channel_store, cx| { + channel_store.create_channel(&channel_name, *parent_id, cx) }) .detach(); + cx.notify(); } - ChannelEditingState::Rename { channel_id } => { + ChannelEditingState::Rename { + channel_id, + pending_name, + } => { + if pending_name.is_some() { + return false; + } + let channel_name = self.channel_name_editor.read(cx).text(cx); + *pending_name = Some(channel_name.clone()); + self.channel_store .update(cx, |channel_store, cx| { - channel_store.rename(channel_id, &channel_name, cx) + channel_store.rename(*channel_id, &channel_name, cx) }) .detach(); + cx.notify(); } } - self.update_entries(false, cx); + cx.focus_self(); true } else { false @@ -1844,17 +1926,30 @@ impl CollabPanel { } fn new_root_channel(&mut self, cx: &mut ViewContext) { - self.channel_editing_state = Some(ChannelEditingState::Create { parent_id: None }); - self.update_entries(true, cx); + self.channel_editing_state = Some(ChannelEditingState::Create { + parent_id: None, + pending_name: None, + }); + self.update_entries(false, cx); + self.select_channel_editor(); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } + fn select_channel_editor(&mut self) { + self.selection = self.entries.iter().position(|entry| match entry { + ListEntry::ChannelEditor { .. } => true, + _ => false, + }); + } + fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext) { self.channel_editing_state = Some(ChannelEditingState::Create { parent_id: Some(action.channel_id), + pending_name: None, }); - self.update_entries(true, cx); + self.update_entries(false, cx); + self.select_channel_editor(); cx.focus(self.channel_name_editor.as_any()); cx.notify(); } @@ -1887,19 +1982,22 @@ impl CollabPanel { } fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext) { - if let Some(channel) = self - .channel_store - .read(cx) - .channel_for_id(action.channel_id) - { + let channel_store = self.channel_store.read(cx); + if !channel_store.is_user_admin(action.channel_id) { + return; + } + if let Some(channel) = channel_store.channel_for_id(action.channel_id) { self.channel_editing_state = Some(ChannelEditingState::Rename { channel_id: action.channel_id, + pending_name: None, }); self.channel_name_editor.update(cx, |editor, cx| { editor.set_text(channel.name.clone(), cx); editor.select_all(&Default::default(), cx); }); - self.update_entries(true, cx); + cx.focus(self.channel_name_editor.as_any()); + self.update_entries(false, cx); + self.select_channel_editor(); } } @@ -2069,8 +2167,12 @@ impl View for CollabPanel { if !self.has_focus { self.has_focus = true; if !self.context_menu.is_focused(cx) { - if self.channel_editing_state.is_some() { - cx.focus(&self.channel_name_editor); + if let Some(editing_state) = &self.channel_editing_state { + if editing_state.pending_name().is_none() { + cx.focus(&self.channel_name_editor); + } else { + cx.focus(&self.filter_editor); + } } else { cx.focus(&self.filter_editor); } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 13b4c60aad4f20fb60b0b15fa1146d6e1dc5316d..fc9a66753cf21d3930d1a2804ed19bac22128348 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -131,17 +131,17 @@ message Envelope { RefreshInlayHints refresh_inlay_hints = 118; CreateChannel create_channel = 119; - CreateChannelResponse create_channel_response = 120; + ChannelResponse channel_response = 120; InviteChannelMember invite_channel_member = 121; RemoveChannelMember remove_channel_member = 122; RespondToChannelInvite respond_to_channel_invite = 123; UpdateChannels update_channels = 124; - JoinChannel join_channel = 126; - RemoveChannel remove_channel = 127; - GetChannelMembers get_channel_members = 128; - GetChannelMembersResponse get_channel_members_response = 129; - SetChannelMemberAdmin set_channel_member_admin = 130; - RenameChannel rename_channel = 131; + JoinChannel join_channel = 125; + RemoveChannel remove_channel = 126; + GetChannelMembers get_channel_members = 127; + GetChannelMembersResponse get_channel_members_response = 128; + SetChannelMemberAdmin set_channel_member_admin = 129; + RenameChannel rename_channel = 130; } } @@ -921,8 +921,8 @@ message CreateChannel { optional uint64 parent_id = 2; } -message CreateChannelResponse { - uint64 channel_id = 1; +message ChannelResponse { + Channel channel = 1; } message InviteChannelMember { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index d3a30911314c19a0939781348e61908f1d8fec4e..92732b00b5dc19ab16136c1ac9511a54f6d2e932 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -146,7 +146,7 @@ messages!( (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), - (CreateChannelResponse, Foreground), + (ChannelResponse, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), @@ -262,7 +262,7 @@ request_messages!( (CopyProjectEntry, ProjectEntryResponse), (CreateProjectEntry, ProjectEntryResponse), (CreateRoom, CreateRoomResponse), - (CreateChannel, CreateChannelResponse), + (CreateChannel, ChannelResponse), (DeclineCall, Ack), (DeleteProjectEntry, ProjectEntryResponse), (ExpandProjectEntry, ExpandProjectEntryResponse), @@ -305,7 +305,7 @@ request_messages!( (JoinChannel, JoinRoomResponse), (RemoveChannel, Ack), (RenameProjectEntry, ProjectEntryResponse), - (RenameChannel, Ack), + (RenameChannel, ChannelResponse), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), (ShareProject, ShareProjectResponse), diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 0979760b88f34cbe230d847c3b2017f0b8b00d2c..6c10da7482b7b42cba1876afbd671d858897ac39 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -358,7 +358,7 @@ export default function contacts_panel(): any { face_overlap: 8, channel_editor: { padding: { - left: 8, + left: name_margin, } } } From ff1261b3008d4e0bc06bdc6d39ab6bb8a69101d5 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Fri, 11 Aug 2023 13:32:46 -0400 Subject: [PATCH 068/105] WIP Restyle channel modal Co-Authored-By: Mikayla Maki --- .../src/collab_panel/channel_modal.rs | 15 ++-- crates/theme/src/theme.rs | 2 +- styles/src/component/input.ts | 26 ++++++ styles/src/component/text_button.ts | 9 +- styles/src/style_tree/channel_modal.ts | 88 ++++++------------- 5 files changed, 64 insertions(+), 76 deletions(-) create mode 100644 styles/src/component/input.ts diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 09be3798a6037bc2c9acb18c47acaac0a9f044d5..77401d269c28cdcda217687eaef6e8e2174d54b6 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -175,19 +175,16 @@ impl View for ChannelModal { this.set_mode(mode, cx); } }) - .with_cursor_style(if active { - CursorStyle::Arrow - } else { - CursorStyle::PointingHand - }) + .with_cursor_style(CursorStyle::PointingHand) .into_any() } Flex::column() - .with_child(Label::new( - format!("#{}", channel.name), - theme.header.clone(), - )) + .with_child( + Label::new(format!("#{}", channel.name), theme.header.text.clone()) + .contained() + .with_style(theme.header.container.clone()), + ) .with_child(Flex::row().with_children([ render_mode_button::( Mode::InviteMembers, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 1756f91fb8e4cc96ec22d6f80a90b67b9c7505f2..9025bf1cd29fbbf9f0c7015f51a74bd849e998a9 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -253,7 +253,7 @@ pub struct CollabPanel { pub struct ChannelModal { pub container: ContainerStyle, pub height: f32, - pub header: TextStyle, + pub header: ContainedText, pub mode_button: Toggleable>, pub picker: Picker, pub row_height: f32, diff --git a/styles/src/component/input.ts b/styles/src/component/input.ts new file mode 100644 index 0000000000000000000000000000000000000000..52d0b42d97fa1414ebcae2f09a9b37dab5b89de4 --- /dev/null +++ b/styles/src/component/input.ts @@ -0,0 +1,26 @@ +import { useTheme } from "../common" +import { background, border, text } from "../style_tree/components" + +export const input = () => { + const theme = useTheme() + + return { + background: background(theme.highest), + corner_radius: 8, + min_width: 200, + max_width: 500, + placeholder_text: text(theme.highest, "mono", "disabled"), + selection: theme.players[0], + text: text(theme.highest, "mono", "default"), + border: border(theme.highest), + margin: { + right: 12, + }, + padding: { + top: 3, + bottom: 3, + left: 12, + right: 8, + } + } +} diff --git a/styles/src/component/text_button.ts b/styles/src/component/text_button.ts index 58b2a1cbf2ff31a8e4fc50b9e1165bca78ec6b4a..3311081a6f8048ac475745fdc970a0620b7f7412 100644 --- a/styles/src/component/text_button.ts +++ b/styles/src/component/text_button.ts @@ -13,6 +13,7 @@ interface TextButtonOptions { | Theme["lowest"] | Theme["middle"] | Theme["highest"] + variant?: "default" | "ghost" color?: keyof Theme["lowest"] margin?: Partial text_properties?: TextProperties @@ -23,6 +24,7 @@ type ToggleableTextButtonOptions = TextButtonOptions & { } export function text_button({ + variant = "default", color, layer, margin, @@ -59,7 +61,7 @@ export function text_button({ }, state: { default: { - background: background(layer ?? theme.lowest, color), + background: variant !== "ghost" ? background(layer ?? theme.lowest, color) : null, color: foreground(layer ?? theme.lowest, color), }, hovered: { @@ -76,14 +78,15 @@ export function text_button({ export function toggleable_text_button( theme: Theme, - { color, active_color, margin }: ToggleableTextButtonOptions + { variant, color, active_color, margin }: ToggleableTextButtonOptions ) { if (!color) color = "base" return toggleable({ state: { - inactive: text_button({ color, margin }), + inactive: text_button({ variant, color, margin }), active: text_button({ + variant, color: active_color ? active_color : color, margin, layer: theme.middle, diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 447522070bac2206f071035cc3cadc160d2dbd2a..764ab9fc9396aef7aee2b7796c32037c8765f1f3 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -2,6 +2,8 @@ import { useTheme } from "../theme" import { interactive, toggleable } from "../element" import { background, border, foreground, text } from "./components" import picker from "./picker" +import { input } from "../component/input" +import { toggleable_text_button } from "../component/text_button" export default function channel_modal(): any { const theme = useTheme() @@ -19,29 +21,10 @@ export default function channel_modal(): any { delete picker_style.shadow delete picker_style.border - const picker_input = { - background: background(theme.middle, "on"), - corner_radius: 6, - text: text(theme.middle, "mono"), - placeholder_text: text(theme.middle, "mono", "on", "disabled", { - size: "xs", - }), - selection: theme.players[0], - border: border(theme.middle), - padding: { - bottom: 8, - left: 8, - right: 8, - top: 4, - }, - margin: { - left: side_margin, - right: side_margin, - bottom: 8, - }, - } + const picker_input = input() return { + // This is used for the icons that are rendered to the right of channel Members in both UIs member_icon: { background: background(theme.middle), padding: { @@ -53,6 +36,7 @@ export default function channel_modal(): any { width: 5, color: foreground(theme.middle, "accent"), }, + // This is used for the icons that are rendered to the right of channel invites in both UIs invitee_icon: { background: background(theme.middle), padding: { @@ -89,54 +73,32 @@ export default function channel_modal(): any { } }, container: { - background: background(theme.lowest), - border: border(theme.lowest), + background: background(theme.middle), + border: border(theme.middle), shadow: theme.modal_shadow, corner_radius: 12, padding: { - bottom: 4, - left: 20, - right: 20, - top: 20, + bottom: 0, + left: 0, + right: 0, + top: 0, }, }, height: 400, - header: text(theme.middle, "sans", "on", { size: "lg" }), - mode_button: toggleable({ - base: interactive({ - base: { - ...text(theme.middle, "sans", { size: "xs" }), - border: border(theme.middle, "active"), - corner_radius: 4, - padding: { - top: 3, - bottom: 3, - left: 7, - right: 7, - }, - - margin: { left: 6, top: 6, bottom: 6 }, - }, - state: { - hovered: { - ...text(theme.middle, "sans", "default", { size: "xs" }), - background: background(theme.middle, "hovered"), - border: border(theme.middle, "active"), - }, - }, - }), - state: { - active: { - default: { - color: foreground(theme.middle, "accent"), - }, - hovered: { - color: foreground(theme.middle, "accent", "hovered"), - }, - clicked: { - color: foreground(theme.middle, "accent", "pressed"), - }, - }, + header: { + ...text(theme.middle, "sans", "on", { size: "lg" }), + padding: { + left: 6, + } + }, + mode_button: toggleable_text_button(theme, { + variant: "ghost", + layer: theme.middle, + active_color: "accent", + margin: { + top: 8, + bottom: 8, + right: 4 } }), picker: { From 9b5551a079e93a7e698efd79e94df8e9e76d15b6 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Fri, 11 Aug 2023 11:35:51 -0700 Subject: [PATCH 069/105] split into body and header --- .../src/collab_panel/channel_modal.rs | 53 +++++++++++-------- crates/theme/src/theme.rs | 9 ++-- styles/src/style_tree/channel_modal.ts | 36 ++++++++----- 3 files changed, 60 insertions(+), 38 deletions(-) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 77401d269c28cdcda217687eaef6e8e2174d54b6..f72eafe7da2e1f171201615825fb5e9c06fed9db 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -181,31 +181,42 @@ impl View for ChannelModal { Flex::column() .with_child( - Label::new(format!("#{}", channel.name), theme.header.text.clone()) + Flex::column() + .with_child( + Label::new(format!("#{}", channel.name), theme.title.text.clone()) + .contained() + .with_style(theme.title.container.clone()), + ) + .with_child(Flex::row().with_children([ + render_mode_button::( + Mode::InviteMembers, + "Invite members", + mode, + theme, + cx, + ), + render_mode_button::( + Mode::ManageMembers, + "Manage members", + mode, + theme, + cx, + ), + ])) + .expanded() .contained() - .with_style(theme.header.container.clone()), + .with_style(theme.header), + ) + .with_child( + ChildView::new(&self.picker, cx) + .contained() + .with_style(theme.body), ) - .with_child(Flex::row().with_children([ - render_mode_button::( - Mode::InviteMembers, - "Invite members", - mode, - theme, - cx, - ), - render_mode_button::( - Mode::ManageMembers, - "Manage members", - mode, - theme, - cx, - ), - ])) - .with_child(ChildView::new(&self.picker, cx)) .constrained() - .with_max_height(theme.height) + .with_max_height(theme.max_height) + .with_max_width(theme.max_width) .contained() - .with_style(theme.container) + .with_style(theme.modal) .into_any() } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 9025bf1cd29fbbf9f0c7015f51a74bd849e998a9..f455cfca7338b5e490c68bbebfc220d6b5abba90 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -251,9 +251,9 @@ pub struct CollabPanel { #[derive(Deserialize, Default, JsonSchema)] pub struct ChannelModal { - pub container: ContainerStyle, - pub height: f32, - pub header: ContainedText, + pub max_height: f32, + pub max_width: f32, + pub title: ContainedText, pub mode_button: Toggleable>, pub picker: Picker, pub row_height: f32, @@ -264,6 +264,9 @@ pub struct ChannelModal { pub member_icon: Icon, pub invitee_icon: Icon, pub member_tag: ContainedText, + pub modal: ContainerStyle, + pub header: ContainerStyle, + pub body: ContainerStyle, } #[derive(Deserialize, Default, JsonSchema)] diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index 764ab9fc9396aef7aee2b7796c32037c8765f1f3..d09ab2db7b6a2004114180d59a0bfa41161d3198 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -24,6 +24,25 @@ export default function channel_modal(): any { const picker_input = input() return { + header: { + background: background(theme.middle, "accent"), + border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }), + }, + body: { + background: background(theme.middle), + }, + modal: { + background: background(theme.middle), + shadow: theme.modal_shadow, + corner_radius: 12, + padding: { + bottom: 0, + left: 0, + right: 0, + top: 0, + }, + + }, // This is used for the icons that are rendered to the right of channel Members in both UIs member_icon: { background: background(theme.middle), @@ -72,20 +91,9 @@ export default function channel_modal(): any { right: 4, } }, - container: { - background: background(theme.middle), - border: border(theme.middle), - shadow: theme.modal_shadow, - corner_radius: 12, - padding: { - bottom: 0, - left: 0, - right: 0, - top: 0, - }, - }, - height: 400, - header: { + max_height: 400, + max_width: 540, + title: { ...text(theme.middle, "sans", "on", { size: "lg" }), padding: { left: 6, From 3856137b6e5f2a19130c3323b8947f2aa1f95428 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 13:17:57 -0400 Subject: [PATCH 070/105] Add list empty state style --- styles/src/style_tree/channel_modal.ts | 1 - styles/src/style_tree/collab_panel.ts | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts index c21c26e0ef8ef927afb142126d468e7c196b4911..b0621743fd0b3f8a034b77a475b359da840a24c1 100644 --- a/styles/src/style_tree/channel_modal.ts +++ b/styles/src/style_tree/channel_modal.ts @@ -1,5 +1,4 @@ import { useTheme } from "../theme" -import { interactive, toggleable } from "../element" import { background, border, foreground, text } from "./components" import picker from "./picker" import { input } from "../component/input" diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 6c10da7482b7b42cba1876afbd671d858897ac39..627d5868b671a8be8254b5ea55d36fcdebbfb979 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -269,6 +269,10 @@ export default function contacts_panel(): any { }, }, }), + list_empty_state: { + ...text(layer, "ui_sans", "variant", { size: "sm" }), + padding: side_padding + }, contact_avatar: { corner_radius: 10, width: 18, From fde9653ad865242a250a10b90938735e2b1712ea Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 14 Aug 2023 10:23:50 -0700 Subject: [PATCH 071/105] Add placeholder implementation --- crates/collab_ui/src/collab_panel.rs | 23 +++++++++++++++++++++++ crates/theme/src/theme.rs | 1 + styles/src/style_tree/collab_panel.ts | 4 ++++ 3 files changed, 28 insertions(+) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 869c159c42c4904981962693732d51f9df75b741..b71749121df57da1c751e4747b4b026748efaf65 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -200,6 +200,7 @@ enum ListEntry { contact: Arc, calling: bool, }, + ContactPlaceholder, } impl Entity for CollabPanel { @@ -368,6 +369,9 @@ impl CollabPanel { ListEntry::ChannelEditor { depth } => { this.render_channel_editor(&theme, *depth, cx) } + ListEntry::ContactPlaceholder => { + this.render_contact_placeholder(&theme.collab_panel) + } } }); @@ -821,6 +825,10 @@ impl CollabPanel { } } + if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() { + self.entries.push(ListEntry::ContactPlaceholder); + } + if select_same_item { if let Some(prev_selected_entry) = prev_selected_entry { self.selection.take(); @@ -1394,6 +1402,16 @@ impl CollabPanel { event_handler.into_any() } + fn render_contact_placeholder(&self, theme: &theme::CollabPanel) -> AnyElement { + Label::new( + "Add contacts to begin collaborating", + theme.placeholder.text.clone(), + ) + .contained() + .with_style(theme.placeholder.container) + .into_any() + } + fn render_channel_editor( &self, theme: &theme::Theme, @@ -2385,6 +2403,11 @@ impl PartialEq for ListEntry { return depth == other_depth; } } + ListEntry::ContactPlaceholder => { + if let ListEntry::ContactPlaceholder = other { + return true; + } + } } false } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index f455cfca7338b5e490c68bbebfc220d6b5abba90..f9c7f37baf9e88d2b3d4c79896d2877cded36b12 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,6 +220,7 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, + pub placeholder: ContainedText, pub log_in_button: Interactive, pub channel_editor: ContainerStyle, pub channel_hash: Icon, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 627d5868b671a8be8254b5ea55d36fcdebbfb979..a6ff3c68d514e3312695146e2f41459d9c6b9b67 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -110,6 +110,10 @@ export default function contacts_panel(): any { return { channel_modal: channel_modal(), + placeholder: { + ...text(theme.middle, "sans", "default", { size: "sm" }), + padding: 5, + }, log_in_button: interactive({ base: { background: background(theme.middle), From b07555b6dfab51cb7d5c143a9f0988f861c08f38 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 14 Aug 2023 10:34:00 -0700 Subject: [PATCH 072/105] Make empty state interactive --- crates/collab_ui/src/collab_panel.rs | 27 +++++++++++++------ crates/theme/src/theme.rs | 2 +- styles/src/style_tree/collab_panel.ts | 38 +++++++++++++++++++++------ 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b71749121df57da1c751e4747b4b026748efaf65..ed042dbf4ee6df6de6bd11afbcc103b42645d9ba 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -370,7 +370,7 @@ impl CollabPanel { this.render_channel_editor(&theme, *depth, cx) } ListEntry::ContactPlaceholder => { - this.render_contact_placeholder(&theme.collab_panel) + this.render_contact_placeholder(&theme.collab_panel, is_selected, cx) } } }); @@ -1402,13 +1402,23 @@ impl CollabPanel { event_handler.into_any() } - fn render_contact_placeholder(&self, theme: &theme::CollabPanel) -> AnyElement { - Label::new( - "Add contacts to begin collaborating", - theme.placeholder.text.clone(), - ) - .contained() - .with_style(theme.placeholder.container) + fn render_contact_placeholder( + &self, + theme: &theme::CollabPanel, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + enum AddContacts {} + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme.list_empty_state.style_for(is_selected, state); + Label::new("Add contacts to begin collaborating", style.text.clone()) + .contained() + .with_style(style.container) + .into_any() + }) + .on_click(MouseButton::Left, |_, this, cx| { + this.toggle_contact_finder(cx); + }) .into_any() } @@ -1861,6 +1871,7 @@ impl CollabPanel { ListEntry::Channel(channel) => { self.join_channel(channel.id, cx); } + ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx), _ => {} } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index f9c7f37baf9e88d2b3d4c79896d2877cded36b12..cd31e312d4e2682f0aae6ebe9c41143abe6a8506 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,7 +220,7 @@ pub struct CopilotAuthAuthorized { pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, - pub placeholder: ContainedText, + pub list_empty_state: Toggleable>, pub log_in_button: Interactive, pub channel_editor: ContainerStyle, pub channel_hash: Icon, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index a6ff3c68d514e3312695146e2f41459d9c6b9b67..3df2dd13d2275373adcdf0c5921e113347363f14 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -110,10 +110,6 @@ export default function contacts_panel(): any { return { channel_modal: channel_modal(), - placeholder: { - ...text(theme.middle, "sans", "default", { size: "sm" }), - padding: 5, - }, log_in_button: interactive({ base: { background: background(theme.middle), @@ -273,10 +269,36 @@ export default function contacts_panel(): any { }, }, }), - list_empty_state: { - ...text(layer, "ui_sans", "variant", { size: "sm" }), - padding: side_padding - }, + list_empty_state: toggleable({ + base: interactive({ + base: { + ...text(layer, "ui_sans", "variant", { size: "sm" }), + padding: side_padding + + }, + state: { + clicked: { + background: background(layer, "pressed"), + }, + }, + }), + state: { + inactive: { + hovered: { + background: background(layer, "hovered"), + }, + }, + active: { + default: { + ...text(layer, "ui_sans", "active", { size: "sm" }), + background: background(layer, "active"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }, + }), contact_avatar: { corner_radius: 10, width: 18, From b6f3dd51a0d8f62fb9f1c6805b73fff417f50760 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Mon, 14 Aug 2023 10:47:29 -0700 Subject: [PATCH 073/105] Move default collab panel to the right --- assets/settings/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 08faedbed6bd1e872f26ef6a0112f6afee0a5fd2..2ddf4a137fbbc9d7df35fe520923725399bf430f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -126,7 +126,7 @@ // Whether to show the collaboration panel button in the status bar. "button": true, // Where to dock channels panel. Can be 'left' or 'right'. - "dock": "left", + "dock": "right", // Default width of the channels panel. "default_width": 240 }, From 2bb9f7929d5777044d616744abbe194f012b8890 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 11:36:49 -0700 Subject: [PATCH 074/105] Structure the contact finder more similarly to the channel modal Co-authored-by: Mikayla --- crates/collab_ui/src/collab_panel.rs | 6 +- .../src/collab_panel/channel_modal.rs | 20 ++- .../src/collab_panel/contact_finder.rs | 141 ++++++++++++++-- crates/theme/src/theme.rs | 23 +-- crates/vcs_menu/src/lib.rs | 2 +- styles/src/style_tree/app.ts | 1 - styles/src/style_tree/channel_modal.ts | 153 ----------------- styles/src/style_tree/collab_modals.ts | 159 ++++++++++++++++++ styles/src/style_tree/collab_panel.ts | 6 +- styles/src/style_tree/contact_finder.ts | 72 ++++---- 10 files changed, 351 insertions(+), 232 deletions(-) delete mode 100644 styles/src/style_tree/channel_modal.ts create mode 100644 styles/src/style_tree/collab_modals.ts diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index ed042dbf4ee6df6de6bd11afbcc103b42645d9ba..0e99497ceff1b826901aac4427d5fc310c0638d0 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -7,7 +7,7 @@ use call::ActiveCall; use client::{ proto::PeerId, Channel, ChannelEvent, ChannelId, ChannelStore, Client, Contact, User, UserStore, }; -use contact_finder::build_contact_finder; + use context_menu::{ContextMenu, ContextMenuItem}; use db::kvp::KEY_VALUE_STORE; use editor::{Cancel, Editor}; @@ -46,6 +46,8 @@ use workspace::{ use crate::face_pile::FacePile; use channel_modal::ChannelModal; +use self::contact_finder::ContactFinder; + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RemoveChannel { channel_id: u64, @@ -1945,7 +1947,7 @@ impl CollabPanel { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(cx, |_, cx| { cx.add_view(|cx| { - let finder = build_contact_finder(self.user_store.clone(), cx); + let mut finder = ContactFinder::new(self.user_store.clone(), cx); finder.set_query(self.filter_editor.read(cx).text(cx), cx); finder }) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index f72eafe7da2e1f171201615825fb5e9c06fed9db..12c923594f2c6c6d78e07fad481c652d14f6bad5 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -66,7 +66,7 @@ impl ChannelModal { }, cx, ) - .with_theme(|theme| theme.collab_panel.channel_modal.picker.clone()) + .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone()) }); cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); @@ -143,7 +143,7 @@ impl View for ChannelModal { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = &theme::current(cx).collab_panel.channel_modal; + let theme = &theme::current(cx).collab_panel.tabbed_modal; let mode = self.picker.read(cx).delegate().mode; let Some(channel) = self @@ -160,12 +160,12 @@ impl View for ChannelModal { mode: Mode, text: &'static str, current_mode: Mode, - theme: &theme::ChannelModal, + theme: &theme::TabbedModal, cx: &mut ViewContext, ) -> AnyElement { let active = mode == current_mode; MouseEventHandler::::new(0, cx, move |state, _| { - let contained_text = theme.mode_button.style_for(active, state); + let contained_text = theme.tab_button.style_for(active, state); Label::new(text, contained_text.text.clone()) .contained() .with_style(contained_text.container.clone()) @@ -367,11 +367,17 @@ impl PickerDelegate for ChannelModalDelegate { selected: bool, cx: &gpui::AppContext, ) -> AnyElement> { - let theme = &theme::current(cx).collab_panel.channel_modal; + let full_theme = &theme::current(cx); + let theme = &full_theme.collab_panel.channel_modal; + let tabbed_modal = &full_theme.collab_panel.tabbed_modal; let (user, admin) = self.user_at_index(ix).unwrap(); let request_status = self.member_status(user.id, cx); - let style = theme.picker.item.in_state(selected).style_for(mouse_state); + let style = tabbed_modal + .picker + .item + .in_state(selected) + .style_for(mouse_state); let in_manage = matches!(self.mode, Mode::ManageMembers); @@ -448,7 +454,7 @@ impl PickerDelegate for ChannelModalDelegate { .contained() .with_style(style.container) .constrained() - .with_height(theme.row_height) + .with_height(tabbed_modal.row_height) .into_any(); if selected { diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 41fff2af4384147e49950b1026b4c553bfec2b24..4cc7034f49a46607bd28a3d4eff5b75503625d36 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -1,28 +1,127 @@ use client::{ContactRequestStatus, User, UserStore}; -use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext}; +use gpui::{ + elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle, +}; use picker::{Picker, PickerDelegate, PickerEvent}; use std::sync::Arc; use util::TryFutureExt; +use workspace::Modal; pub fn init(cx: &mut AppContext) { Picker::::init(cx); } -pub type ContactFinder = Picker; +pub struct ContactFinder { + picker: ViewHandle>, + has_focus: bool, +} -pub fn build_contact_finder( - user_store: ModelHandle, - cx: &mut ViewContext, -) -> ContactFinder { - Picker::new( - ContactFinderDelegate { - user_store, - potential_contacts: Arc::from([]), - selected_index: 0, - }, - cx, - ) - .with_theme(|theme| theme.picker.clone()) +impl ContactFinder { + pub fn new(user_store: ModelHandle, cx: &mut ViewContext) -> Self { + let picker = cx.add_view(|cx| { + Picker::new( + ContactFinderDelegate { + user_store, + potential_contacts: Arc::from([]), + selected_index: 0, + }, + cx, + ) + .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone()) + }); + + cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); + + Self { + picker, + has_focus: false, + } + } + + pub fn set_query(&mut self, query: String, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| { + picker.set_query(query, cx); + }); + } +} + +impl Entity for ContactFinder { + type Event = PickerEvent; +} + +impl View for ContactFinder { + fn ui_name() -> &'static str { + "ContactFinder" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let full_theme = &theme::current(cx); + let theme = &full_theme.collab_panel.tabbed_modal; + + fn render_mode_button( + text: &'static str, + theme: &theme::TabbedModal, + _cx: &mut ViewContext, + ) -> AnyElement { + 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( + 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.has_focus = true; + if cx.is_self_focused() { + cx.focus(&self.picker) + } + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + 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 { @@ -97,7 +196,9 @@ impl PickerDelegate for ContactFinderDelegate { selected: bool, cx: &gpui::AppContext, ) -> AnyElement> { - let theme = &theme::current(cx).contact_finder; + 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 request_status = self.user_store.read(cx).contact_request_status(user); @@ -113,7 +214,11 @@ impl PickerDelegate for ContactFinderDelegate { } else { &theme.contact_button }; - let style = theme.picker.item.in_state(selected).style_for(mouse_state); + let style = tabbed_modal + .picker + .item + .in_state(selected) + .style_for(mouse_state); Flex::row() .with_children(user.avatar.clone().map(|avatar| { Image::from_data(avatar) @@ -145,7 +250,7 @@ impl PickerDelegate for ContactFinderDelegate { .contained() .with_style(style.container) .constrained() - .with_height(theme.row_height) + .with_height(tabbed_modal.row_height) .into_any() } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index cd31e312d4e2682f0aae6ebe9c41143abe6a8506..1e11fbbf824880d19f24e74aa04e0bbed8e0e52d 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -48,7 +48,6 @@ pub struct Theme { pub collab_panel: CollabPanel, pub project_panel: ProjectPanel, pub command_palette: CommandPalette, - pub contact_finder: ContactFinder, pub picker: Picker, pub editor: Editor, pub search: Search, @@ -224,6 +223,8 @@ pub struct CollabPanel { pub log_in_button: Interactive, pub channel_editor: ContainerStyle, pub channel_hash: Icon, + pub tabbed_modal: TabbedModal, + pub contact_finder: ContactFinder, pub channel_modal: ChannelModal, pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, @@ -251,13 +252,20 @@ pub struct CollabPanel { } #[derive(Deserialize, Default, JsonSchema)] -pub struct ChannelModal { - pub max_height: f32, - pub max_width: f32, +pub struct TabbedModal { + pub tab_button: Toggleable>, + pub modal: ContainerStyle, + pub header: ContainerStyle, + pub body: ContainerStyle, pub title: ContainedText, - pub mode_button: Toggleable>, pub picker: Picker, + pub max_height: f32, + pub max_width: f32, pub row_height: f32, +} + +#[derive(Deserialize, Default, JsonSchema)] +pub struct ChannelModal { pub contact_avatar: ImageStyle, pub contact_username: ContainerStyle, pub remove_member_button: ContainedText, @@ -265,9 +273,6 @@ pub struct ChannelModal { pub member_icon: Icon, pub invitee_icon: Icon, pub member_tag: ContainedText, - pub modal: ContainerStyle, - pub header: ContainerStyle, - pub body: ContainerStyle, } #[derive(Deserialize, Default, JsonSchema)] @@ -286,8 +291,6 @@ pub struct TreeBranch { #[derive(Deserialize, Default, JsonSchema)] pub struct ContactFinder { - pub picker: Picker, - pub row_height: f32, pub contact_avatar: ImageStyle, pub contact_username: ContainerStyle, pub contact_button: IconButton, diff --git a/crates/vcs_menu/src/lib.rs b/crates/vcs_menu/src/lib.rs index 384b6224690a7c488cfcf87ccd0b251e3aaf7916..8be8ad2bdee2669c8cda3a50ba2cd4f7e5f61116 100644 --- a/crates/vcs_menu/src/lib.rs +++ b/crates/vcs_menu/src/lib.rs @@ -256,7 +256,7 @@ impl PickerDelegate for BranchListDelegate { .contained() .with_style(style.container) .constrained() - .with_height(theme.contact_finder.row_height) + .with_height(theme.collab_panel.tabbed_modal.row_height) .into_any() } fn render_header( diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index be6d4d42bf04cd4424e74b0a77b2a8862aeee38b..ee5e19e11137a48097c6873dfca03d00c68d2279 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -46,7 +46,6 @@ export default function app(): any { project_diagnostics: project_diagnostics(), project_panel: project_panel(), collab_panel: collab_panel(), - contact_finder: contact_finder(), toolbar_dropdown_menu: toolbar_dropdown_menu(), search: search(), shared_screen: shared_screen(), diff --git a/styles/src/style_tree/channel_modal.ts b/styles/src/style_tree/channel_modal.ts deleted file mode 100644 index b0621743fd0b3f8a034b77a475b359da840a24c1..0000000000000000000000000000000000000000 --- a/styles/src/style_tree/channel_modal.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useTheme } from "../theme" -import { background, border, foreground, text } from "./components" -import picker from "./picker" -import { input } from "../component/input" -import { toggleable_text_button } from "../component/text_button" - -export default function channel_modal(): any { - const theme = useTheme() - - const side_margin = 6 - const contact_button = { - background: background(theme.middle, "variant"), - color: foreground(theme.middle, "variant"), - icon_width: 8, - button_width: 16, - corner_radius: 8, - } - - const picker_style = picker() - delete picker_style.shadow - delete picker_style.border - - const picker_input = input() - - return { - header: { - background: background(theme.middle, "accent"), - border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }), - corner_radii: { - top_right: 12, - top_left: 12, - } - }, - body: { - background: background(theme.middle), - corner_radii: { - bottom_right: 12, - bottom_left: 12, - } - }, - modal: { - background: background(theme.middle), - shadow: theme.modal_shadow, - corner_radius: 12, - padding: { - bottom: 0, - left: 0, - right: 0, - top: 0, - }, - - }, - // This is used for the icons that are rendered to the right of channel Members in both UIs - member_icon: { - background: background(theme.middle), - padding: { - bottom: 4, - left: 4, - right: 4, - top: 4, - }, - width: 5, - color: foreground(theme.middle, "accent"), - }, - // This is used for the icons that are rendered to the right of channel invites in both UIs - invitee_icon: { - background: background(theme.middle), - padding: { - bottom: 4, - left: 4, - right: 4, - top: 4, - }, - width: 5, - color: foreground(theme.middle, "accent"), - }, - remove_member_button: { - ...text(theme.middle, "sans", { size: "xs" }), - background: background(theme.middle), - padding: { - left: 7, - right: 7 - } - }, - cancel_invite_button: { - ...text(theme.middle, "sans", { size: "xs" }), - background: background(theme.middle), - }, - member_tag: { - ...text(theme.middle, "sans", { size: "xs" }), - border: border(theme.middle, "active"), - background: background(theme.middle), - margin: { - left: 8, - }, - padding: { - left: 4, - right: 4, - } - }, - max_height: 400, - max_width: 540, - title: { - ...text(theme.middle, "sans", "on", { size: "lg" }), - padding: { - left: 6, - } - }, - mode_button: toggleable_text_button(theme, { - variant: "ghost", - layer: theme.middle, - active_color: "accent", - margin: { - top: 8, - bottom: 8, - right: 4 - } - }), - picker: { - empty_container: {}, - item: { - ...picker_style.item, - margin: { left: side_margin, right: side_margin }, - }, - no_matches: picker_style.no_matches, - input_editor: picker_input, - empty_input_editor: picker_input, - header: picker_style.header, - footer: picker_style.footer, - }, - row_height: 28, - contact_avatar: { - corner_radius: 10, - width: 18, - }, - contact_username: { - padding: { - left: 8, - }, - }, - contact_button: { - ...contact_button, - hover: { - background: background(theme.middle, "variant", "hovered"), - }, - }, - disabled_contact_button: { - ...contact_button, - background: background(theme.middle, "disabled"), - color: foreground(theme.middle, "disabled"), - }, - } -} diff --git a/styles/src/style_tree/collab_modals.ts b/styles/src/style_tree/collab_modals.ts new file mode 100644 index 0000000000000000000000000000000000000000..95690b5d85f20febd3b22080d670614ee008a09e --- /dev/null +++ b/styles/src/style_tree/collab_modals.ts @@ -0,0 +1,159 @@ +import { useTheme } from "../theme" +import { background, border, foreground, text } from "./components" +import picker from "./picker" +import { input } from "../component/input" +import { toggleable_text_button } from "../component/text_button" +import contact_finder from "./contact_finder" + +export default function channel_modal(): any { + const theme = useTheme() + + const side_margin = 6 + const contact_button = { + background: background(theme.middle, "variant"), + color: foreground(theme.middle, "variant"), + icon_width: 8, + button_width: 16, + corner_radius: 8, + } + + const picker_style = picker() + delete picker_style.shadow + delete picker_style.border + + const picker_input = input() + + return { + contact_finder: contact_finder(), + tabbed_modal: { + tab_button: toggleable_text_button(theme, { + variant: "ghost", + layer: theme.middle, + active_color: "accent", + margin: { + top: 8, + bottom: 8, + right: 4 + } + }), + row_height: 28, + header: { + background: background(theme.middle, "accent"), + border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }), + corner_radii: { + top_right: 12, + top_left: 12, + } + }, + body: { + background: background(theme.middle), + corner_radii: { + bottom_right: 12, + bottom_left: 12, + } + }, + modal: { + background: background(theme.middle), + shadow: theme.modal_shadow, + corner_radius: 12, + padding: { + bottom: 0, + left: 0, + right: 0, + top: 0, + }, + + }, + max_height: 400, + max_width: 540, + title: { + ...text(theme.middle, "sans", "on", { size: "lg" }), + padding: { + left: 6, + } + }, + picker: { + empty_container: {}, + item: { + ...picker_style.item, + margin: { left: side_margin, right: side_margin }, + }, + no_matches: picker_style.no_matches, + input_editor: picker_input, + empty_input_editor: picker_input, + header: picker_style.header, + footer: picker_style.footer, + }, + }, + channel_modal: { + // This is used for the icons that are rendered to the right of channel Members in both UIs + member_icon: { + background: background(theme.middle), + padding: { + bottom: 4, + left: 4, + right: 4, + top: 4, + }, + width: 5, + color: foreground(theme.middle, "accent"), + }, + // This is used for the icons that are rendered to the right of channel invites in both UIs + invitee_icon: { + background: background(theme.middle), + padding: { + bottom: 4, + left: 4, + right: 4, + top: 4, + }, + width: 5, + color: foreground(theme.middle, "accent"), + }, + remove_member_button: { + ...text(theme.middle, "sans", { size: "xs" }), + background: background(theme.middle), + padding: { + left: 7, + right: 7 + } + }, + cancel_invite_button: { + ...text(theme.middle, "sans", { size: "xs" }), + background: background(theme.middle), + }, + member_tag: { + ...text(theme.middle, "sans", { size: "xs" }), + border: border(theme.middle, "active"), + background: background(theme.middle), + margin: { + left: 8, + }, + padding: { + left: 4, + right: 4, + } + }, + contact_avatar: { + corner_radius: 10, + width: 18, + }, + contact_username: { + padding: { + left: 8, + }, + }, + contact_button: { + ...contact_button, + hover: { + background: background(theme.middle, "variant", "hovered"), + }, + }, + disabled_contact_button: { + ...contact_button, + background: background(theme.middle, "disabled"), + color: foreground(theme.middle, "disabled"), + }, + } + } +} diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 3df2dd13d2275373adcdf0c5921e113347363f14..06170901e9a1736496f307d1006285b195d0fd0c 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -7,9 +7,7 @@ import { } from "./components" import { interactive, toggleable } from "../element" import { useTheme } from "../theme" -import channel_modal from "./channel_modal" -import { icon_button, toggleable_icon_button } from "../component/icon_button" - +import collab_modals from "./collab_modals" export default function contacts_panel(): any { const theme = useTheme() @@ -109,7 +107,7 @@ export default function contacts_panel(): any { return { - channel_modal: channel_modal(), + ...collab_modals(), log_in_button: interactive({ base: { background: background(theme.middle), diff --git a/styles/src/style_tree/contact_finder.ts b/styles/src/style_tree/contact_finder.ts index aa88a9f26a9462106a999325f887fe747b5fe6ed..04f95cc367fa48024cc53556f84d281a8a2e6899 100644 --- a/styles/src/style_tree/contact_finder.ts +++ b/styles/src/style_tree/contact_finder.ts @@ -1,11 +1,11 @@ -import picker from "./picker" +// import picker from "./picker" import { background, border, foreground, text } from "./components" import { useTheme } from "../theme" export default function contact_finder(): any { const theme = useTheme() - const side_margin = 6 + // const side_margin = 6 const contact_button = { background: background(theme.middle, "variant"), color: foreground(theme.middle, "variant"), @@ -14,42 +14,42 @@ export default function contact_finder(): any { corner_radius: 8, } - const picker_style = picker() - const picker_input = { - background: background(theme.middle, "on"), - corner_radius: 6, - text: text(theme.middle, "mono"), - placeholder_text: text(theme.middle, "mono", "on", "disabled", { - size: "xs", - }), - selection: theme.players[0], - border: border(theme.middle), - padding: { - bottom: 4, - left: 8, - right: 8, - top: 4, - }, - margin: { - left: side_margin, - right: side_margin, - }, - } + // const picker_style = picker() + // const picker_input = { + // background: background(theme.middle, "on"), + // corner_radius: 6, + // text: text(theme.middle, "mono"), + // placeholder_text: text(theme.middle, "mono", "on", "disabled", { + // size: "xs", + // }), + // selection: theme.players[0], + // border: border(theme.middle), + // padding: { + // bottom: 4, + // left: 8, + // right: 8, + // top: 4, + // }, + // margin: { + // left: side_margin, + // right: side_margin, + // }, + // } return { - picker: { - empty_container: {}, - item: { - ...picker_style.item, - margin: { left: side_margin, right: side_margin }, - }, - no_matches: picker_style.no_matches, - input_editor: picker_input, - empty_input_editor: picker_input, - header: picker_style.header, - footer: picker_style.footer, - }, - row_height: 28, + // picker: { + // empty_container: {}, + // item: { + // ...picker_style.item, + // margin: { left: side_margin, right: side_margin }, + // }, + // no_matches: picker_style.no_matches, + // input_editor: picker_input, + // empty_input_editor: picker_input, + // header: picker_style.header, + // footer: picker_style.footer, + // }, + // row_height: 28, contact_avatar: { corner_radius: 10, width: 18, From 3b10ae93107313251b3c96abd2f27ce5f366062f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 11:57:15 -0700 Subject: [PATCH 075/105] Add icon before the empty contacts text Co-authored-by: Mikayla --- crates/collab_ui/src/collab_panel.rs | 16 +++++++++++++++- crates/theme/src/theme.rs | 2 ++ styles/src/style_tree/collab_panel.ts | 9 +++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 0e99497ceff1b826901aac4427d5fc310c0638d0..274eeb9f2dbe7bec10504e824bb1aba0537b3bd0 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1413,7 +1413,21 @@ impl CollabPanel { enum AddContacts {} MouseEventHandler::::new(0, cx, |state, _| { let style = theme.list_empty_state.style_for(is_selected, state); - Label::new("Add contacts to begin collaborating", style.text.clone()) + Flex::row() + .with_child( + Svg::new("icons/plus_16.svg") + .with_color(theme.list_empty_icon.color) + .constrained() + .with_width(theme.list_empty_icon.width) + .aligned() + .left(), + ) + .with_child( + Label::new("Add a contact", style.text.clone()) + .contained() + .with_style(theme.list_empty_label_container), + ) + .align_children_center() .contained() .with_style(style.container) .into_any() diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 1e11fbbf824880d19f24e74aa04e0bbed8e0e52d..4919eb93c77c18e2cf214b5569d701031da9fef5 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -220,6 +220,8 @@ pub struct CollabPanel { #[serde(flatten)] pub container: ContainerStyle, pub list_empty_state: Toggleable>, + pub list_empty_icon: Icon, + pub list_empty_label_container: ContainerStyle, pub log_in_button: Interactive, pub channel_editor: ContainerStyle, pub channel_hash: Icon, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 06170901e9a1736496f307d1006285b195d0fd0c..8f8b8e504fca9d71a02fc3e34022edf565f31bb9 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -267,6 +267,15 @@ export default function contacts_panel(): any { }, }, }), + list_empty_label_container: { + margin: { + left: 5, + } + }, + list_empty_icon: { + color: foreground(layer, "on"), + width: 16, + }, list_empty_state: toggleable({ base: interactive({ base: { From 4a5b2fa5dc49261395cbd54092e49654c95f28b8 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 15:13:57 -0400 Subject: [PATCH 076/105] Add ghost button variants --- styles/src/component/button.ts | 6 ++++++ styles/src/component/icon_button.ts | 12 ++++++++---- styles/src/component/text_button.ts | 9 ++++++--- 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 styles/src/component/button.ts diff --git a/styles/src/component/button.ts b/styles/src/component/button.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba72851768edf7d46c6c8cb774b777b92ecc6da1 --- /dev/null +++ b/styles/src/component/button.ts @@ -0,0 +1,6 @@ +export const ButtonVariant = { + Default: 'default', + Ghost: 'ghost' +} as const + +export type Variant = typeof ButtonVariant[keyof typeof ButtonVariant] diff --git a/styles/src/component/icon_button.ts b/styles/src/component/icon_button.ts index 6887fc7c30e1f234fd043bb115c2658039b5f806..ae3fa763e72a47943102e549538381db3516e7c4 100644 --- a/styles/src/component/icon_button.ts +++ b/styles/src/component/icon_button.ts @@ -1,6 +1,7 @@ import { interactive, toggleable } from "../element" import { background, foreground } from "../style_tree/components" import { useTheme, Theme } from "../theme" +import { ButtonVariant, Variant } from "./button" export type Margin = { top: number @@ -16,17 +17,20 @@ interface IconButtonOptions { | Theme["highest"] color?: keyof Theme["lowest"] margin?: Partial + variant?: Variant } type ToggleableIconButtonOptions = IconButtonOptions & { active_color?: keyof Theme["lowest"] } -export function icon_button({ color, margin, layer }: IconButtonOptions) { +export function icon_button({ color, margin, layer, variant = ButtonVariant.Default }: IconButtonOptions) { const theme = useTheme() if (!color) color = "base" + const background_color = variant === ButtonVariant.Ghost ? null : background(layer ?? theme.lowest, color) + const m = { top: margin?.top ?? 0, bottom: margin?.bottom ?? 0, @@ -51,7 +55,7 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) { }, state: { default: { - background: background(layer ?? theme.lowest, color), + background: background_color, color: foreground(layer ?? theme.lowest, color), }, hovered: { @@ -68,13 +72,13 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) { export function toggleable_icon_button( theme: Theme, - { color, active_color, margin }: ToggleableIconButtonOptions + { color, active_color, margin, variant }: ToggleableIconButtonOptions ) { if (!color) color = "base" return toggleable({ state: { - inactive: icon_button({ color, margin }), + inactive: icon_button({ color, margin, variant }), active: icon_button({ color: active_color ? active_color : color, margin, diff --git a/styles/src/component/text_button.ts b/styles/src/component/text_button.ts index 3311081a6f8048ac475745fdc970a0620b7f7412..c7bdb26e7bec5a34dc6d021cb3c164b767824fde 100644 --- a/styles/src/component/text_button.ts +++ b/styles/src/component/text_button.ts @@ -6,6 +6,7 @@ import { text, } from "../style_tree/components" import { useTheme, Theme } from "../theme" +import { ButtonVariant, Variant } from "./button" import { Margin } from "./icon_button" interface TextButtonOptions { @@ -13,7 +14,7 @@ interface TextButtonOptions { | Theme["lowest"] | Theme["middle"] | Theme["highest"] - variant?: "default" | "ghost" + variant?: Variant color?: keyof Theme["lowest"] margin?: Partial text_properties?: TextProperties @@ -24,7 +25,7 @@ type ToggleableTextButtonOptions = TextButtonOptions & { } export function text_button({ - variant = "default", + variant = ButtonVariant.Default, color, layer, margin, @@ -33,6 +34,8 @@ export function text_button({ const theme = useTheme() if (!color) color = "base" + const background_color = variant === ButtonVariant.Ghost ? null : background(layer ?? theme.lowest, color) + const text_options: TextProperties = { size: "xs", weight: "normal", @@ -61,7 +64,7 @@ export function text_button({ }, state: { default: { - background: variant !== "ghost" ? background(layer ?? theme.lowest, color) : null, + background: background_color, color: foreground(layer ?? theme.lowest, color), }, hovered: { From 8531cdaff72f245e858b50db2e5d6aac845739e9 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 15:50:37 -0400 Subject: [PATCH 077/105] Style channels panel items --- styles/src/component/text_button.ts | 4 +- styles/src/style_tree/collab_panel.ts | 245 +++++++++----------------- 2 files changed, 89 insertions(+), 160 deletions(-) diff --git a/styles/src/component/text_button.ts b/styles/src/component/text_button.ts index c7bdb26e7bec5a34dc6d021cb3c164b767824fde..2be2dd19cbae54b8025413189fa0cd5168d08977 100644 --- a/styles/src/component/text_button.ts +++ b/styles/src/component/text_button.ts @@ -30,7 +30,7 @@ export function text_button({ layer, margin, text_properties, -}: TextButtonOptions) { +}: TextButtonOptions = {}) { const theme = useTheme() if (!color) color = "base" @@ -81,7 +81,7 @@ export function text_button({ export function toggleable_text_button( theme: Theme, - { variant, color, active_color, margin }: ToggleableTextButtonOptions + { variant, color, active_color, margin }: ToggleableTextButtonOptions = {} ) { if (!color) color = "base" diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 8f8b8e504fca9d71a02fc3e34022edf565f31bb9..b8969e2b9a32d52467af7384af8f5d3846b06810 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -8,12 +8,16 @@ import { import { interactive, toggleable } from "../element" import { useTheme } from "../theme" import collab_modals from "./collab_modals" +import { text_button } from "../component/text_button" +import { toggleable_icon_button } from "../component/icon_button" export default function contacts_panel(): any { const theme = useTheme() - const name_margin = 8 - const side_padding = 12 + const NAME_MARGIN = 6 as const + const SPACING = 12 as const + const INDENT_SIZE = 8 as const + const ITEM_HEIGHT = 28 as const const layer = theme.middle @@ -24,6 +28,7 @@ export default function contacts_panel(): any { button_width: 16, corner_radius: 8, } + const project_row = { guest_avatar_spacing: 4, height: 24, @@ -32,186 +37,111 @@ export default function contacts_panel(): any { width: 14, }, name: { - ...text(layer, "mono", { size: "sm" }), + ...text(layer, "ui_sans", { size: "sm" }), margin: { - left: name_margin, - right: 6, + left: NAME_MARGIN, + right: 4, }, }, guests: { margin: { - left: name_margin, - right: name_margin, + left: NAME_MARGIN, + right: NAME_MARGIN, }, }, padding: { - left: side_padding, - right: side_padding, + left: SPACING, + right: SPACING, }, } - const headerButton = toggleable({ - state: { - inactive: interactive({ - base: { - corner_radius: 6, - padding: { - top: 2, - bottom: 2, - left: 4, - right: 4, - }, - icon_width: 14, - icon_height: 14, - button_width: 20, - button_height: 16, - color: foreground(layer, "on"), - }, - state: { - default: { - }, - hovered: { - background: background(layer, "base", "hovered"), - }, - clicked: { - background: background(layer, "base", "pressed"), - }, - }, - }), - active: interactive({ - base: { - corner_radius: 6, - padding: { - top: 2, - bottom: 2, - left: 4, - right: 4, - }, - icon_width: 14, - icon_height: 14, - button_width: 20, - button_height: 16, - color: foreground(layer, "on"), - }, - state: { - default: { - background: background(layer, "base", "active"), - }, - clicked: { - background: background(layer, "base", "active"), - }, - }, - }), - }, - }) + const icon_style = { + color: foreground(layer, "variant"), + width: 14, + } + const header_icon_button = toggleable_icon_button(theme, { + layer: theme.middle, + variant: "ghost", + }) - return { - ...collab_modals(), - log_in_button: interactive({ + const subheader_row = toggleable({ + base: interactive({ base: { - background: background(theme.middle), - border: border(theme.middle, "active"), - corner_radius: 4, - margin: { - top: 16, - left: 16, - right: 16, - }, + ...text(layer, "ui_sans", { size: "sm" }), padding: { - top: 3, - bottom: 3, - left: 7, - right: 7, + left: SPACING, + right: SPACING, }, - ...text(theme.middle, "sans", "default", { size: "sm" }), }, state: { hovered: { - ...text(theme.middle, "sans", "default", { size: "sm" }), - background: background(theme.middle, "hovered"), - border: border(theme.middle, "active"), + background: background(layer, "hovered"), }, clicked: { - ...text(theme.middle, "sans", "default", { size: "sm" }), - background: background(theme.middle, "pressed"), - border: border(theme.middle, "active"), + background: background(layer, "pressed"), }, }, }), - background: background(layer), + state: { + active: { + default: { + ...text(layer, "ui_sans", "active", { size: "sm" }), + background: background(layer, "active"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }, + }) + + const filter_input = { + background: background(layer, "on"), + corner_radius: 6, + text: text(layer, "ui_sans", "base"), + placeholder_text: text(layer, "ui_sans", "base", "disabled", { + size: "xs", + }), + selection: theme.players[0], + border: border(layer, "on"), padding: { - top: 12, + bottom: 4, + left: 8, + right: 8, + top: 4, }, - user_query_editor: { - background: background(layer, "on"), - corner_radius: 6, - text: text(layer, "mono", "on"), - placeholder_text: text(layer, "mono", "on", "disabled", { - size: "xs", - }), - selection: theme.players[0], - border: border(layer, "on"), - padding: { - bottom: 4, - left: 8, - right: 8, - top: 4, - }, - margin: { - left: side_padding, - right: side_padding, - }, + margin: { + left: SPACING, + right: SPACING, }, - channel_hash: { - color: foreground(layer, "on"), - width: 14, + } + + return { + ...collab_modals(), + log_in_button: text_button(), + background: background(layer), + padding: { + top: SPACING, }, + user_query_editor: filter_input, + channel_hash: icon_style, user_query_editor_height: 33, - add_contact_button: headerButton, - add_channel_button: headerButton, - leave_call_button: headerButton, - row_height: 28, - channel_indent: 10, + add_contact_button: header_icon_button, + add_channel_button: header_icon_button, + leave_call_button: header_icon_button, + row_height: ITEM_HEIGHT, + channel_indent: INDENT_SIZE, section_icon_size: 8, header_row: { - ...text(layer, "mono", { size: "sm", weight: "bold" }), - margin: { top: 14 }, + ...text(layer, "ui_sans", { size: "sm", weight: "bold" }), + margin: { top: SPACING }, padding: { - left: side_padding, - right: side_padding, + left: SPACING, + right: SPACING, }, }, - subheader_row: toggleable({ - base: interactive({ - base: { - ...text(layer, "mono", { size: "sm" }), - padding: { - left: side_padding, - right: side_padding, - }, - }, - state: { - hovered: { - background: background(layer, "hovered"), - }, - clicked: { - background: background(layer, "pressed"), - }, - }, - }), - state: { - active: { - default: { - ...text(layer, "mono", "active", { size: "sm" }), - background: background(layer, "active"), - }, - clicked: { - background: background(layer, "pressed"), - }, - }, - }, - }), + subheader_row, leave_call: interactive({ base: { background: background(layer), @@ -240,8 +170,8 @@ export default function contacts_panel(): any { base: interactive({ base: { padding: { - left: side_padding, - right: side_padding, + left: SPACING, + right: SPACING, }, }, state: { @@ -258,7 +188,7 @@ export default function contacts_panel(): any { }, active: { default: { - ...text(layer, "mono", "active", { size: "sm" }), + ...text(layer, "ui_sans", "active", { size: "sm" }), background: background(layer, "active"), }, clicked: { @@ -280,7 +210,7 @@ export default function contacts_panel(): any { base: interactive({ base: { ...text(layer, "ui_sans", "variant", { size: "sm" }), - padding: side_padding + padding: SPACING }, state: { @@ -323,12 +253,12 @@ export default function contacts_panel(): any { background: foreground(layer, "negative"), }, contact_username: { - ...text(layer, "mono", { size: "sm" }), + ...text(layer, "ui_sans", { size: "sm" }), margin: { - left: name_margin, + left: NAME_MARGIN, }, }, - contact_button_spacing: name_margin, + contact_button_spacing: NAME_MARGIN, contact_button: interactive({ base: { ...contact_button }, state: { @@ -369,9 +299,8 @@ export default function contacts_panel(): any { base: interactive({ base: { ...project_row, - // background: background(layer), icon: { - margin: { left: name_margin }, + margin: { left: NAME_MARGIN }, color: foreground(layer, "variant"), width: 12, }, @@ -395,7 +324,7 @@ export default function contacts_panel(): any { face_overlap: 8, channel_editor: { padding: { - left: name_margin, + left: NAME_MARGIN, } } } From a5534bb30f4bc99bd19d15ac52823d29ddcf397c Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 15:50:42 -0400 Subject: [PATCH 078/105] Add new icons --- assets/icons/ai.svg | 27 +++++++++++++++++++++++++++ assets/icons/arrow_left.svg | 3 +++ assets/icons/arrow_right.svg | 3 +++ assets/icons/chevron_down.svg | 3 +++ assets/icons/chevron_left.svg | 3 +++ assets/icons/chevron_right.svg | 3 +++ assets/icons/chevron_up.svg | 3 +++ assets/icons/conversations.svg | 4 ++++ assets/icons/copilot.svg | 9 +++++++++ assets/icons/copy.svg | 5 +++++ assets/icons/error.svg | 4 ++++ assets/icons/exit.svg | 4 ++++ assets/icons/feedback.svg | 6 ++++++ assets/icons/filter.svg | 3 +++ assets/icons/kebab.svg | 5 +++++ assets/icons/magnifying_glass.svg | 3 +++ assets/icons/match_case.svg | 5 +++++ assets/icons/match_word.svg | 5 +++++ assets/icons/maximize.svg | 4 ++++ assets/icons/microphone.svg | 5 +++++ assets/icons/minimize.svg | 4 ++++ assets/icons/plus.svg | 3 +++ assets/icons/project.svg | 5 +++++ assets/icons/replace.svg | 11 +++++++++++ assets/icons/replace_all.svg | 5 +++++ assets/icons/replace_next.svg | 5 +++++ assets/icons/screen.svg | 4 ++++ assets/icons/split.svg | 5 +++++ assets/icons/success.svg | 4 ++++ assets/icons/terminal.svg | 5 +++++ assets/icons/warning.svg | 5 +++++ assets/icons/x.svg | 3 +++ 32 files changed, 166 insertions(+) create mode 100644 assets/icons/ai.svg create mode 100644 assets/icons/arrow_left.svg create mode 100644 assets/icons/arrow_right.svg create mode 100644 assets/icons/chevron_down.svg create mode 100644 assets/icons/chevron_left.svg create mode 100644 assets/icons/chevron_right.svg create mode 100644 assets/icons/chevron_up.svg create mode 100644 assets/icons/conversations.svg create mode 100644 assets/icons/copilot.svg create mode 100644 assets/icons/copy.svg create mode 100644 assets/icons/error.svg create mode 100644 assets/icons/exit.svg create mode 100644 assets/icons/feedback.svg create mode 100644 assets/icons/filter.svg create mode 100644 assets/icons/kebab.svg create mode 100644 assets/icons/magnifying_glass.svg create mode 100644 assets/icons/match_case.svg create mode 100644 assets/icons/match_word.svg create mode 100644 assets/icons/maximize.svg create mode 100644 assets/icons/microphone.svg create mode 100644 assets/icons/minimize.svg create mode 100644 assets/icons/plus.svg create mode 100644 assets/icons/project.svg create mode 100644 assets/icons/replace.svg create mode 100644 assets/icons/replace_all.svg create mode 100644 assets/icons/replace_next.svg create mode 100644 assets/icons/screen.svg create mode 100644 assets/icons/split.svg create mode 100644 assets/icons/success.svg create mode 100644 assets/icons/terminal.svg create mode 100644 assets/icons/warning.svg create mode 100644 assets/icons/x.svg diff --git a/assets/icons/ai.svg b/assets/icons/ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa046c605087b6ded13f347d622b376afdb437aa --- /dev/null +++ b/assets/icons/ai.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/arrow_left.svg b/assets/icons/arrow_left.svg new file mode 100644 index 0000000000000000000000000000000000000000..186c9c7457c48405508de337fa5d1904f2563f59 --- /dev/null +++ b/assets/icons/arrow_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/arrow_right.svg b/assets/icons/arrow_right.svg new file mode 100644 index 0000000000000000000000000000000000000000..7bae7f4801a10b0ee04dfab93048bbdaf526045a --- /dev/null +++ b/assets/icons/arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000000000000000000000000000000000000..b971555cfa0b8c15daf35522a3f3ef449ffac087 --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_left.svg b/assets/icons/chevron_left.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e61beed5df055132edde2510908324cc8a47fb1 --- /dev/null +++ b/assets/icons/chevron_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_right.svg b/assets/icons/chevron_right.svg new file mode 100644 index 0000000000000000000000000000000000000000..fcd9d83fc203578f5135a5d040999bea6765769e --- /dev/null +++ b/assets/icons/chevron_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_up.svg b/assets/icons/chevron_up.svg new file mode 100644 index 0000000000000000000000000000000000000000..171cdd61c0511aabe2f25463089d3cfd9cbf5039 --- /dev/null +++ b/assets/icons/chevron_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/conversations.svg b/assets/icons/conversations.svg new file mode 100644 index 0000000000000000000000000000000000000000..fe8ad03dda712b2b48d75b480e95ca534c1efb9c --- /dev/null +++ b/assets/icons/conversations.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/copilot.svg b/assets/icons/copilot.svg new file mode 100644 index 0000000000000000000000000000000000000000..06dbf178ae9727569eaa9b6f9bbe986a3d488e48 --- /dev/null +++ b/assets/icons/copilot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 0000000000000000000000000000000000000000..4aa44979c39de058a96548d66a73fe6b437f22eb --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/error.svg b/assets/icons/error.svg new file mode 100644 index 0000000000000000000000000000000000000000..82b9401d08dc8d682fcbbfda15795f6ec3d3de2e --- /dev/null +++ b/assets/icons/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/exit.svg b/assets/icons/exit.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e45535773e4e6f871fd80af25452afb5021fdd4 --- /dev/null +++ b/assets/icons/exit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/feedback.svg b/assets/icons/feedback.svg new file mode 100644 index 0000000000000000000000000000000000000000..2703f7011994869bcad75a7a0f45f1b8e89317af --- /dev/null +++ b/assets/icons/feedback.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/filter.svg b/assets/icons/filter.svg new file mode 100644 index 0000000000000000000000000000000000000000..80ce656f57199246dc036f39e2fead4e19e53168 --- /dev/null +++ b/assets/icons/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/kebab.svg b/assets/icons/kebab.svg new file mode 100644 index 0000000000000000000000000000000000000000..1858c655202cf6940c90278b43241bb1cabc32ac --- /dev/null +++ b/assets/icons/kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/magnifying_glass.svg b/assets/icons/magnifying_glass.svg new file mode 100644 index 0000000000000000000000000000000000000000..0b539adb6c764451234b898a5e6306baabc64d57 --- /dev/null +++ b/assets/icons/magnifying_glass.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/match_case.svg b/assets/icons/match_case.svg new file mode 100644 index 0000000000000000000000000000000000000000..82f4529c1b054d4218812f7b8a2094f54e9a1ae3 --- /dev/null +++ b/assets/icons/match_case.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/match_word.svg b/assets/icons/match_word.svg new file mode 100644 index 0000000000000000000000000000000000000000..69ba8eb9e6bc52e49e4ace4b1526881222672d6c --- /dev/null +++ b/assets/icons/match_word.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg new file mode 100644 index 0000000000000000000000000000000000000000..4dc7755714990ddc5d4b06ffc992859954342c93 --- /dev/null +++ b/assets/icons/maximize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/microphone.svg b/assets/icons/microphone.svg new file mode 100644 index 0000000000000000000000000000000000000000..8974fd939d233b839d03e94e301abb2a955c665a --- /dev/null +++ b/assets/icons/microphone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg new file mode 100644 index 0000000000000000000000000000000000000000..d8941ee1f0ed6a566cf0d07a1b89cefd49d3ee19 --- /dev/null +++ b/assets/icons/minimize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000000000000000000000000000000000000..a54dd0ad66226f3c485c33c221f823da87727789 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/project.svg b/assets/icons/project.svg new file mode 100644 index 0000000000000000000000000000000000000000..525109db4ce74d99074c90e714003720d4e97156 --- /dev/null +++ b/assets/icons/project.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/replace.svg b/assets/icons/replace.svg new file mode 100644 index 0000000000000000000000000000000000000000..af1092189130fea8cfc52a54b2fbc1e71636b1fd --- /dev/null +++ b/assets/icons/replace.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/replace_all.svg b/assets/icons/replace_all.svg new file mode 100644 index 0000000000000000000000000000000000000000..4838e82242f38d41357cf189f761a43535ff51f0 --- /dev/null +++ b/assets/icons/replace_all.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/replace_next.svg b/assets/icons/replace_next.svg new file mode 100644 index 0000000000000000000000000000000000000000..ba751411afc33e6fd79c005fd0f06f2250dd4276 --- /dev/null +++ b/assets/icons/replace_next.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/screen.svg b/assets/icons/screen.svg new file mode 100644 index 0000000000000000000000000000000000000000..49e097b02325ce3644be662896cd7a3a666b6f8f --- /dev/null +++ b/assets/icons/screen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/split.svg b/assets/icons/split.svg new file mode 100644 index 0000000000000000000000000000000000000000..4c131466c2e2dbb0752f3e6eebbe2b92775550df --- /dev/null +++ b/assets/icons/split.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/success.svg b/assets/icons/success.svg new file mode 100644 index 0000000000000000000000000000000000000000..85450cdc433b80f157be94beae5f60c184906f0f --- /dev/null +++ b/assets/icons/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal.svg b/assets/icons/terminal.svg new file mode 100644 index 0000000000000000000000000000000000000000..15dd705b0b313930e1971d24d3774050880b5a4c --- /dev/null +++ b/assets/icons/terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg new file mode 100644 index 0000000000000000000000000000000000000000..6b3d0fd41e979c0704a8f04502c16cfc58c9cb2f --- /dev/null +++ b/assets/icons/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/x.svg b/assets/icons/x.svg new file mode 100644 index 0000000000000000000000000000000000000000..31c5aa31a6b2e90a11249f5dc5c2b4ceb5ffc501 --- /dev/null +++ b/assets/icons/x.svg @@ -0,0 +1,3 @@ + + + From f2d46e0ff954d14ca6ed40569ffef2412ea9ae9b Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 15:57:31 -0400 Subject: [PATCH 079/105] Use new icons in channel panel --- assets/icons/hash.svg | 6 ++++++ assets/icons/html.svg | 5 +++++ assets/icons/lock.svg | 6 ++++++ crates/collab_ui/src/collab_panel.rs | 26 +++++++++++++------------- styles/src/style_tree/collab_panel.ts | 2 +- 5 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 assets/icons/hash.svg create mode 100644 assets/icons/html.svg create mode 100644 assets/icons/lock.svg diff --git a/assets/icons/hash.svg b/assets/icons/hash.svg new file mode 100644 index 0000000000000000000000000000000000000000..f685245ed3c2a2881b2fe1377bf029e8e515ae94 --- /dev/null +++ b/assets/icons/hash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/html.svg b/assets/icons/html.svg new file mode 100644 index 0000000000000000000000000000000000000000..1e676fe313401fc137813827df03cc2c60851df0 --- /dev/null +++ b/assets/icons/html.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 0000000000000000000000000000000000000000..652f45a7e843795c288fdaaf4951d40943e3805d --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 274eeb9f2dbe7bec10504e824bb1aba0537b3bd0..8c63649ef9b41a1b4794ce75ce364f76a85ee3cd 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1189,7 +1189,7 @@ impl CollabPanel { .collab_panel .leave_call_button .style_for(is_selected, state), - "icons/radix/exit.svg", + "icons/exit.svg", ) }) .with_cursor_style(CursorStyle::PointingHand) @@ -1233,7 +1233,7 @@ impl CollabPanel { .collab_panel .add_contact_button .style_for(is_selected, state), - "icons/plus_16.svg", + "icons/plus.svg", ) }) .with_cursor_style(CursorStyle::PointingHand) @@ -1266,9 +1266,9 @@ impl CollabPanel { .with_children(if can_collapse { Some( Svg::new(if is_collapsed { - "icons/chevron_right_8.svg" + "icons/chevron_right.svg" } else { - "icons/chevron_down_8.svg" + "icons/chevron_down.svg" }) .with_color(header_style.text.color) .constrained() @@ -1364,7 +1364,7 @@ impl CollabPanel { cx, |mouse_state, _| { let button_style = theme.contact_button.style_for(mouse_state); - render_icon_button(button_style, "icons/x_mark_8.svg") + render_icon_button(button_style, "icons/x.svg") .aligned() .flex_float() }, @@ -1415,7 +1415,7 @@ impl CollabPanel { let style = theme.list_empty_state.style_for(is_selected, state); Flex::row() .with_child( - Svg::new("icons/plus_16.svg") + Svg::new("icons/plus.svg") .with_color(theme.list_empty_icon.color) .constrained() .with_width(theme.list_empty_icon.width) @@ -1446,7 +1446,7 @@ impl CollabPanel { ) -> AnyElement { Flex::row() .with_child( - Svg::new("icons/channel_hash.svg") + Svg::new("icons/hash.svg") .with_color(theme.collab_panel.channel_hash.color) .constrained() .with_width(theme.collab_panel.channel_hash.width) @@ -1506,7 +1506,7 @@ impl CollabPanel { MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() .with_child( - Svg::new("icons/channel_hash.svg") + Svg::new("icons/hash.svg") .with_color(theme.channel_hash.color) .constrained() .with_width(theme.channel_hash.width) @@ -1572,7 +1572,7 @@ impl CollabPanel { Flex::row() .with_child( - Svg::new("icons/channel_hash.svg") + Svg::new("icons/hash.svg") .with_color(theme.channel_hash.color) .constrained() .with_width(theme.channel_hash.width) @@ -1597,7 +1597,7 @@ impl CollabPanel { } else { theme.contact_button.style_for(mouse_state) }; - render_icon_button(button_style, "icons/x_mark_8.svg").aligned() + render_icon_button(button_style, "icons/x.svg").aligned() }, ) .with_cursor_style(CursorStyle::PointingHand) @@ -1686,7 +1686,7 @@ impl CollabPanel { } else { theme.contact_button.style_for(mouse_state) }; - render_icon_button(button_style, "icons/x_mark_8.svg").aligned() + render_icon_button(button_style, "icons/x.svg").aligned() }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, this, cx| { @@ -1720,7 +1720,7 @@ impl CollabPanel { } else { theme.contact_button.style_for(mouse_state) }; - render_icon_button(button_style, "icons/x_mark_8.svg") + render_icon_button(button_style, "icons/x.svg") .aligned() .flex_float() }) @@ -2340,7 +2340,7 @@ impl Panel for CollabPanel { fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { settings::get::(cx) .button - .then(|| "icons/speech_bubble_12.svg") + .then(|| "icons/conversations.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index b8969e2b9a32d52467af7384af8f5d3846b06810..648fa141a5f9df2e58ef40b9fe0281fd94828c09 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -132,7 +132,7 @@ export default function contacts_panel(): any { leave_call_button: header_icon_button, row_height: ITEM_HEIGHT, channel_indent: INDENT_SIZE, - section_icon_size: 8, + section_icon_size: 14, header_row: { ...text(layer, "ui_sans", { size: "sm", weight: "bold" }), margin: { top: SPACING }, From e0d73842d2c7aeece689f96f9ad6973e0b87b104 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 16:12:39 -0400 Subject: [PATCH 080/105] Continue panel styles --- styles/src/component/indicator.ts | 9 +++++++ styles/src/style_tree/collab_panel.ts | 35 ++++++++++++--------------- 2 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 styles/src/component/indicator.ts diff --git a/styles/src/component/indicator.ts b/styles/src/component/indicator.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a078fb53fea1fc4186d94119b4a344e55e8280f --- /dev/null +++ b/styles/src/component/indicator.ts @@ -0,0 +1,9 @@ +import { background } from "../style_tree/components" +import { Layer, StyleSets } from "../theme" + +export const indicator = ({ layer, color }: { layer: Layer, color: StyleSets }) => ({ + corner_radius: 4, + padding: 4, + margin: { top: 12, left: 12 }, + background: background(layer, color), +}) diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 648fa141a5f9df2e58ef40b9fe0281fd94828c09..6cf6f9b09525a84a29aba778aaddf8ec75309e85 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -10,6 +10,7 @@ import { useTheme } from "../theme" import collab_modals from "./collab_modals" import { text_button } from "../component/text_button" import { toggleable_icon_button } from "../component/icon_button" +import { indicator } from "../component/indicator" export default function contacts_panel(): any { const theme = useTheme() @@ -24,7 +25,7 @@ export default function contacts_panel(): any { const contact_button = { background: background(layer, "on"), color: foreground(layer, "on"), - icon_width: 8, + icon_width: 14, button_width: 16, corner_radius: 8, } @@ -199,19 +200,23 @@ export default function contacts_panel(): any { }), list_empty_label_container: { margin: { - left: 5, + left: NAME_MARGIN, } }, list_empty_icon: { - color: foreground(layer, "on"), - width: 16, + color: foreground(layer, "variant"), + width: 14, }, list_empty_state: toggleable({ base: interactive({ base: { ...text(layer, "ui_sans", "variant", { size: "sm" }), - padding: SPACING - + padding: { + top: SPACING / 2, + bottom: SPACING / 2, + left: SPACING, + right: SPACING + }, }, state: { clicked: { @@ -238,20 +243,10 @@ export default function contacts_panel(): any { }), contact_avatar: { corner_radius: 10, - width: 18, - }, - contact_status_free: { - corner_radius: 4, - padding: 4, - margin: { top: 12, left: 12 }, - background: foreground(layer, "positive"), - }, - contact_status_busy: { - corner_radius: 4, - padding: 4, - margin: { top: 12, left: 12 }, - background: foreground(layer, "negative"), + width: 20, }, + contact_status_free: indicator({ layer, color: "positive" }), + contact_status_busy: indicator({ layer, color: "negative" }), contact_username: { ...text(layer, "ui_sans", { size: "sm" }), margin: { @@ -302,7 +297,7 @@ export default function contacts_panel(): any { icon: { margin: { left: NAME_MARGIN }, color: foreground(layer, "variant"), - width: 12, + width: 14, }, name: { ...project_row.name, From b4b044ccbf7da62311f978d543433239b61b17e8 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 17:01:34 -0400 Subject: [PATCH 081/105] Initial modal styles --- styles/src/component/input.ts | 3 -- styles/src/component/tab.ts | 73 ++++++++++++++++++++++++++ styles/src/style_tree/collab_modals.ts | 38 ++++++++------ 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 styles/src/component/tab.ts diff --git a/styles/src/component/input.ts b/styles/src/component/input.ts index 52d0b42d97fa1414ebcae2f09a9b37dab5b89de4..cadfcc8d4aba63a5a7d0a342e8b904df6f1c5645 100644 --- a/styles/src/component/input.ts +++ b/styles/src/component/input.ts @@ -13,9 +13,6 @@ export const input = () => { selection: theme.players[0], text: text(theme.highest, "mono", "default"), border: border(theme.highest), - margin: { - right: 12, - }, padding: { top: 3, bottom: 3, diff --git a/styles/src/component/tab.ts b/styles/src/component/tab.ts new file mode 100644 index 0000000000000000000000000000000000000000..9938fb93113ba993cc13cfca76569aad518bfe02 --- /dev/null +++ b/styles/src/component/tab.ts @@ -0,0 +1,73 @@ +import { Layer } from "../common" +import { interactive, toggleable } from "../element" +import { Border, text } from "../style_tree/components" + +type TabProps = { + layer: Layer +} + +export const tab = ({ layer }: TabProps) => { + const active_color = text(layer, "sans", "base").color + const inactive_border: Border = { + color: '#FFFFFF00', + width: 1, + bottom: true, + left: false, + right: false, + top: false, + } + const active_border: Border = { + ...inactive_border, + color: active_color, + } + + const base = { + ...text(layer, "sans", "variant"), + padding: { + top: 8, + left: 8, + right: 8, + bottom: 6 + }, + border: inactive_border, + } + + const i = interactive({ + state: { + default: { + ...base + }, + hovered: { + ...base, + ...text(layer, "sans", "base", "hovered") + }, + clicked: { + ...base, + ...text(layer, "sans", "base", "pressed") + }, + } + }) + + return toggleable({ + base: i, + state: { + active: { + default: { + ...i, + ...text(layer, "sans", "base"), + border: active_border, + }, + hovered: { + ...i, + ...text(layer, "sans", "base", "hovered"), + border: active_border + }, + clicked: { + ...i, + ...text(layer, "sans", "base", "pressed"), + border: active_border + }, + } + } + }) +} diff --git a/styles/src/style_tree/collab_modals.ts b/styles/src/style_tree/collab_modals.ts index 95690b5d85f20febd3b22080d670614ee008a09e..c0bf358e71624fc7d4aa6a6e26c57042d1e861ee 100644 --- a/styles/src/style_tree/collab_modals.ts +++ b/styles/src/style_tree/collab_modals.ts @@ -2,13 +2,16 @@ import { useTheme } from "../theme" import { background, border, foreground, text } from "./components" import picker from "./picker" import { input } from "../component/input" -import { toggleable_text_button } from "../component/text_button" import contact_finder from "./contact_finder" +import { tab } from "../component/tab" export default function channel_modal(): any { const theme = useTheme() - const side_margin = 6 + const SPACING = 12 as const + const BUTTON_OFFSET = 6 as const + const ITEM_HEIGHT = 36 as const + const contact_button = { background: background(theme.middle, "variant"), color: foreground(theme.middle, "variant"), @@ -26,20 +29,16 @@ export default function channel_modal(): any { return { contact_finder: contact_finder(), tabbed_modal: { - tab_button: toggleable_text_button(theme, { - variant: "ghost", - layer: theme.middle, - active_color: "accent", - margin: { - top: 8, - bottom: 8, - right: 4 - } - }), - row_height: 28, + tab_button: tab({ layer: theme.middle }), + row_height: ITEM_HEIGHT, header: { - background: background(theme.middle, "accent"), + background: background(theme.lowest), border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }), + padding: { + top: SPACING, + left: SPACING - BUTTON_OFFSET, + right: SPACING - BUTTON_OFFSET, + }, corner_radii: { top_right: 12, top_left: 12, @@ -47,6 +46,13 @@ export default function channel_modal(): any { }, body: { background: background(theme.middle), + padding: { + top: SPACING - 4, + left: SPACING, + right: SPACING, + bottom: SPACING, + + }, corner_radii: { bottom_right: 12, bottom_left: 12, @@ -69,14 +75,14 @@ export default function channel_modal(): any { title: { ...text(theme.middle, "sans", "on", { size: "lg" }), padding: { - left: 6, + left: BUTTON_OFFSET, } }, picker: { empty_container: {}, item: { ...picker_style.item, - margin: { left: side_margin, right: side_margin }, + margin: { left: SPACING, right: SPACING }, }, no_matches: picker_style.no_matches, input_editor: picker_input, From ef73e77d3d6eda1fd17da4a4a450e55bf46c7a77 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 14 Aug 2023 17:15:25 -0400 Subject: [PATCH 082/105] Update some status bar icons and states --- crates/feedback/src/deploy_feedback_button.rs | 2 +- crates/project_panel/src/project_panel.rs | 2 +- crates/terminal_view/src/terminal_panel.rs | 2 +- crates/terminal_view/src/terminal_view.rs | 2 +- styles/src/style_tree/status_bar.ts | 33 ++++++++++--------- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/feedback/src/deploy_feedback_button.rs b/crates/feedback/src/deploy_feedback_button.rs index d197f57fa529fa37e652194441d716f417fe1246..4b9768c07c00c1cc4275fbf6e6faeeef9d9e6059 100644 --- a/crates/feedback/src/deploy_feedback_button.rs +++ b/crates/feedback/src/deploy_feedback_button.rs @@ -44,7 +44,7 @@ impl View for DeployFeedbackButton { .in_state(active) .style_for(state); - Svg::new("icons/feedback_16.svg") + Svg::new("icons/feedback.svg") .with_color(style.icon_color) .constrained() .with_width(style.icon_size) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b7e1259b2c7798745770534f1874ab9870236480..12dfe59864ff80d67705c7e2d069323ad7cb40f9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1658,7 +1658,7 @@ impl workspace::dock::Panel for ProjectPanel { } fn icon_path(&self, _: &WindowContext) -> Option<&'static str> { - Some("icons/folder_tree_16.svg") + Some("icons/project.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 2277fd5dfbfd8295744e455c8f908c96c8b09145..7141cda1720a22994db51031c6a010ab3ac3d399 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -394,7 +394,7 @@ impl Panel for TerminalPanel { } fn icon_path(&self, _: &WindowContext) -> Option<&'static str> { - Some("icons/terminal_12.svg") + Some("icons/terminal.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 970e0115df2545ce9f8979fc5e2efc876d08242f..08a1d08633f79b2a7b8e356b9fcf4dec8cfec706 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -667,7 +667,7 @@ impl Item for TerminalView { Flex::row() .with_child( - gpui::elements::Svg::new("icons/terminal_12.svg") + gpui::elements::Svg::new("icons/terminal.svg") .with_color(tab_theme.label.text.color) .constrained() .with_width(tab_theme.type_icon_width) diff --git a/styles/src/style_tree/status_bar.ts b/styles/src/style_tree/status_bar.ts index d35b721c6cefcc50fc19ea526164ad967fa2578e..2d3b81f7c2fa42d779394a50a8ae4c0b79a09c1a 100644 --- a/styles/src/style_tree/status_bar.ts +++ b/styles/src/style_tree/status_bar.ts @@ -28,16 +28,16 @@ export default function status_bar(): any { right: 6, }, border: border(layer, { top: true, overlay: true }), - cursor_position: text(layer, "sans", "variant", { size: "xs" }), + cursor_position: text(layer, "sans", "base", { size: "xs" }), vim_mode_indicator: { margin: { left: 6 }, - ...text(layer, "mono", "variant", { size: "xs" }), + ...text(layer, "mono", "base", { size: "xs" }), }, active_language: text_button({ - color: "variant" + color: "base" }), - auto_update_progress_message: text(layer, "sans", "variant", { size: "xs" }), - auto_update_done_message: text(layer, "sans", "variant", { size: "xs" }), + auto_update_progress_message: text(layer, "sans", "base", { size: "xs" }), + auto_update_done_message: text(layer, "sans", "base", { size: "xs" }), lsp_status: interactive({ base: { ...diagnostic_status_container, @@ -64,11 +64,11 @@ export default function status_bar(): any { diagnostic_summary: interactive({ base: { height: 20, - icon_width: 16, + icon_width: 14, icon_spacing: 2, summary_spacing: 6, text: text(layer, "sans", { size: "sm" }), - icon_color_ok: foreground(layer, "variant"), + icon_color_ok: foreground(layer, "base"), icon_color_warning: foreground(layer, "warning"), icon_color_error: foreground(layer, "negative"), container_ok: { @@ -111,8 +111,9 @@ export default function status_bar(): any { base: interactive({ base: { ...status_container, - icon_size: 16, - icon_color: foreground(layer, "variant"), + icon_size: 14, + icon_color: foreground(layer, "base"), + background: background(layer, "default"), label: { margin: { left: 6 }, ...text(layer, "sans", { size: "xs" }), @@ -120,23 +121,25 @@ export default function status_bar(): any { }, state: { hovered: { - icon_color: foreground(layer, "hovered"), - background: background(layer, "variant"), + background: background(layer, "hovered"), }, + clicked: { + background: background(layer, "pressed"), + } }, }), state: { active: { default: { - icon_color: foreground(layer, "active"), - background: background(layer, "active"), + icon_color: foreground(layer, "accent", "default"), + background: background(layer, "default"), }, hovered: { - icon_color: foreground(layer, "hovered"), + icon_color: foreground(layer, "accent", "hovered"), background: background(layer, "hovered"), }, clicked: { - icon_color: foreground(layer, "pressed"), + icon_color: foreground(layer, "accent", "pressed"), background: background(layer, "pressed"), }, }, From d7f21a9155419b104317a57c48739ae0d0052341 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 16:27:35 -0700 Subject: [PATCH 083/105] Ensure channels are sorted alphabetically Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 170 ++++---- crates/client/src/channel_store_tests.rs | 92 ++++- crates/collab/src/tests/channel_tests.rs | 468 ++++++++++++----------- crates/collab_ui/src/collab_panel.rs | 72 ++-- 4 files changed, 466 insertions(+), 336 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 206423579aa036624834b6752771c8bcac48e8b8..8217e6cbc8bb7432b5c5eba284ca24118a77bc0b 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -14,7 +14,8 @@ pub type ChannelId = u64; pub type UserId = u64; pub struct ChannelStore { - channels: Vec>, + channels_by_id: HashMap>, + channel_paths: Vec>, channel_invitations: Vec>, channel_participants: HashMap>>, channels_with_admin_privileges: HashSet, @@ -29,8 +30,6 @@ pub struct ChannelStore { pub struct Channel { pub id: ChannelId, pub name: String, - pub parent_id: Option, - pub depth: usize, } pub struct ChannelMembership { @@ -69,10 +68,11 @@ impl ChannelStore { if matches!(status, Status::ConnectionLost | Status::SignedOut) { if let Some(this) = this.upgrade(&cx) { this.update(&mut cx, |this, cx| { - this.channels.clear(); + this.channels_by_id.clear(); this.channel_invitations.clear(); this.channel_participants.clear(); this.channels_with_admin_privileges.clear(); + this.channel_paths.clear(); this.outgoing_invites.clear(); cx.notify(); }); @@ -83,8 +83,9 @@ impl ChannelStore { } }); Self { - channels: vec![], - channel_invitations: vec![], + channels_by_id: HashMap::default(), + channel_invitations: Vec::default(), + channel_paths: Vec::default(), channel_participants: Default::default(), channels_with_admin_privileges: Default::default(), outgoing_invites: Default::default(), @@ -95,31 +96,43 @@ impl ChannelStore { } } - pub fn channels(&self) -> &[Arc] { - &self.channels + pub fn channel_count(&self) -> usize { + self.channel_paths.len() + } + + pub fn channels(&self) -> impl '_ + Iterator)> { + self.channel_paths.iter().map(move |path| { + let id = path.last().unwrap(); + let channel = self.channel_for_id(*id).unwrap(); + (path.len() - 1, channel) + }) + } + + pub fn channel_at_index(&self, ix: usize) -> Option<(usize, &Arc)> { + let path = self.channel_paths.get(ix)?; + let id = path.last().unwrap(); + let channel = self.channel_for_id(*id).unwrap(); + Some((path.len() - 1, channel)) } pub fn channel_invitations(&self) -> &[Arc] { &self.channel_invitations } - pub fn channel_for_id(&self, channel_id: ChannelId) -> Option> { - self.channels.iter().find(|c| c.id == channel_id).cloned() + pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc> { + self.channels_by_id.get(&channel_id) } - pub fn is_user_admin(&self, mut channel_id: ChannelId) -> bool { - loop { - if self.channels_with_admin_privileges.contains(&channel_id) { - return true; - } - if let Some(channel) = self.channel_for_id(channel_id) { - if let Some(parent_id) = channel.parent_id { - channel_id = parent_id; - continue; - } + pub fn is_user_admin(&self, channel_id: ChannelId) -> bool { + self.channel_paths.iter().any(|path| { + if let Some(ix) = path.iter().position(|id| *id == channel_id) { + path[..=ix] + .iter() + .any(|id| self.channels_with_admin_privileges.contains(id)) + } else { + false } - return false; - } + }) } pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc] { @@ -373,69 +386,78 @@ impl ChannelStore { payload: proto::UpdateChannels, cx: &mut ModelContext, ) { - self.channels - .retain(|channel| !payload.remove_channels.contains(&channel.id)); - self.channel_invitations - .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); - self.channel_participants - .retain(|channel_id, _| !payload.remove_channels.contains(channel_id)); - self.channels_with_admin_privileges - .retain(|channel_id| !payload.remove_channels.contains(channel_id)); - + if !payload.remove_channel_invitations.is_empty() { + self.channel_invitations + .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); + } for channel in payload.channel_invitations { - if let Some(existing_channel) = self + match self .channel_invitations - .iter_mut() - .find(|c| c.id == channel.id) + .binary_search_by_key(&channel.id, |c| c.id) { - let existing_channel = Arc::make_mut(existing_channel); - existing_channel.name = channel.name; - continue; + Ok(ix) => Arc::make_mut(&mut self.channel_invitations[ix]).name = channel.name, + Err(ix) => self.channel_invitations.insert( + ix, + Arc::new(Channel { + id: channel.id, + name: channel.name, + }), + ), } - - self.channel_invitations.insert( - 0, - Arc::new(Channel { - id: channel.id, - name: channel.name, - parent_id: None, - depth: 0, - }), - ); } - for channel in payload.channels { - if let Some(existing_channel) = self.channels.iter_mut().find(|c| c.id == channel.id) { - let existing_channel = Arc::make_mut(existing_channel); - existing_channel.name = channel.name; - continue; + let channels_changed = !payload.channels.is_empty() || !payload.remove_channels.is_empty(); + if channels_changed { + if !payload.remove_channels.is_empty() { + self.channels_by_id + .retain(|channel_id, _| !payload.remove_channels.contains(channel_id)); + self.channel_participants + .retain(|channel_id, _| !payload.remove_channels.contains(channel_id)); + self.channels_with_admin_privileges + .retain(|channel_id| !payload.remove_channels.contains(channel_id)); } - if let Some(parent_id) = channel.parent_id { - if let Some(ix) = self.channels.iter().position(|c| c.id == parent_id) { - let parent_channel = &self.channels[ix]; - let depth = parent_channel.depth + 1; - self.channels.insert( - ix + 1, - Arc::new(Channel { - id: channel.id, - name: channel.name, - parent_id: Some(parent_id), - depth, - }), - ); + for channel in payload.channels { + if let Some(existing_channel) = self.channels_by_id.get_mut(&channel.id) { + let existing_channel = Arc::make_mut(existing_channel); + existing_channel.name = channel.name; + continue; } - } else { - self.channels.insert( - 0, + self.channels_by_id.insert( + channel.id, Arc::new(Channel { id: channel.id, name: channel.name, - parent_id: None, - depth: 0, }), ); + + if let Some(parent_id) = channel.parent_id { + let mut ix = 0; + while ix < self.channel_paths.len() { + let path = &self.channel_paths[ix]; + if path.ends_with(&[parent_id]) { + let mut new_path = path.clone(); + new_path.push(channel.id); + self.channel_paths.insert(ix + 1, new_path); + ix += 1; + } + ix += 1; + } + } else { + self.channel_paths.push(vec![channel.id]); + } } + + self.channel_paths.sort_by(|a, b| { + let a = Self::channel_path_sorting_key(a, &self.channels_by_id); + let b = Self::channel_path_sorting_key(b, &self.channels_by_id); + a.cmp(b) + }); + self.channel_paths.dedup(); + self.channel_paths.retain(|path| { + path.iter() + .all(|channel_id| self.channels_by_id.contains_key(channel_id)) + }); } for permission in payload.channel_permissions { @@ -492,4 +514,12 @@ impl ChannelStore { cx.notify(); } + + fn channel_path_sorting_key<'a>( + path: &'a [ChannelId], + channels_by_id: &'a HashMap>, + ) -> impl 'a + Iterator> { + path.iter() + .map(|id| Some(channels_by_id.get(id)?.name.as_str())) + } } diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs index f74169eb2a57eec59ffe34e58371f8d952875334..3a3f3842eb5ea0a37bc737715929a543a9a2987f 100644 --- a/crates/client/src/channel_store_tests.rs +++ b/crates/client/src/channel_store_tests.rs @@ -36,8 +36,8 @@ fn test_update_channels(cx: &mut AppContext) { &channel_store, &[ // - (0, "a", false), - (0, "b", true), + (0, "a".to_string(), false), + (0, "b".to_string(), true), ], cx, ); @@ -64,15 +64,76 @@ fn test_update_channels(cx: &mut AppContext) { assert_channels( &channel_store, &[ - (0, "a", false), - (1, "y", false), - (0, "b", true), - (1, "x", true), + (0, "a".to_string(), false), + (1, "y".to_string(), false), + (0, "b".to_string(), true), + (1, "x".to_string(), true), ], cx, ); } +#[gpui::test] +fn test_dangling_channel_paths(cx: &mut AppContext) { + let http = FakeHttpClient::with_404_response(); + let client = Client::new(http.clone(), cx); + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); + + let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx)); + + update_channels( + &channel_store, + proto::UpdateChannels { + channels: vec![ + proto::Channel { + id: 0, + name: "a".to_string(), + parent_id: None, + }, + proto::Channel { + id: 1, + name: "b".to_string(), + parent_id: Some(0), + }, + proto::Channel { + id: 2, + name: "c".to_string(), + parent_id: Some(1), + }, + ], + channel_permissions: vec![proto::ChannelPermission { + channel_id: 0, + is_admin: true, + }], + ..Default::default() + }, + cx, + ); + // Sanity check + assert_channels( + &channel_store, + &[ + // + (0, "a".to_string(), true), + (1, "b".to_string(), true), + (2, "c".to_string(), true), + ], + cx, + ); + + update_channels( + &channel_store, + proto::UpdateChannels { + remove_channels: vec![1, 2], + ..Default::default() + }, + cx, + ); + + // Make sure that the 1/2/3 path is gone + assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx); +} + fn update_channels( channel_store: &ModelHandle, message: proto::UpdateChannels, @@ -84,15 +145,20 @@ fn update_channels( #[track_caller] fn assert_channels( channel_store: &ModelHandle, - expected_channels: &[(usize, &str, bool)], + expected_channels: &[(usize, String, bool)], cx: &AppContext, ) { - channel_store.read_with(cx, |store, _| { - let actual = store + let actual = channel_store.read_with(cx, |store, _| { + store .channels() - .iter() - .map(|c| (c.depth, c.name.as_str(), store.is_user_admin(c.id))) - .collect::>(); - assert_eq!(actual, expected_channels); + .map(|(depth, channel)| { + ( + depth, + channel.name.to_string(), + store.is_user_admin(channel.id), + ) + }) + .collect::>() }); + assert_eq!(actual, expected_channels); } diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 0dc6d478d14226f080a0a3ce5bb22dee1a3b35e0..f1157ce7ae8ac0fc5b2e95d38035dbe434c56300 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -3,8 +3,8 @@ use crate::{ tests::{room_participants, RoomParticipants, TestServer}, }; use call::ActiveCall; -use client::{Channel, ChannelMembership, User}; -use gpui::{executor::Deterministic, TestAppContext}; +use client::{ChannelId, ChannelMembership, ChannelStore, User}; +use gpui::{executor::Deterministic, ModelHandle, TestAppContext}; use rpc::{proto, RECEIVE_TIMEOUT}; use std::sync::Arc; @@ -35,31 +35,28 @@ async fn test_core_channels( .unwrap(); deterministic.run_until_parked(); - client_a.channel_store().read_with(cx_a, |channels, _| { - assert_eq!( - channels.channels(), - &[ - Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - }), - Arc::new(Channel { - id: channel_b_id, - name: "channel-b".to_string(), - parent_id: Some(channel_a_id), - depth: 1, - }) - ] - ); - assert!(channels.is_user_admin(channel_a_id)); - assert!(channels.is_user_admin(channel_b_id)); - }); + assert_channels( + client_a.channel_store(), + cx_a, + &[ + ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: true, + }, + ExpectedChannel { + id: channel_b_id, + name: "channel-b".to_string(), + depth: 1, + user_is_admin: true, + }, + ], + ); - client_b - .channel_store() - .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert!(channels.channels().collect::>().is_empty()) + }); // Invite client B to channel A as client A. client_a @@ -78,17 +75,16 @@ async fn test_core_channels( // Client A sees that B has been invited. deterministic.run_until_parked(); - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channel_invitations(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - })] - ) - }); + assert_channel_invitations( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: false, + }], + ); let members = client_a .channel_store() @@ -125,28 +121,25 @@ async fn test_core_channels( deterministic.run_until_parked(); // Client B now sees that they are a member of channel A and its existing subchannels. - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!(channels.channel_invitations(), &[]); - assert_eq!( - channels.channels(), - &[ - Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - }), - Arc::new(Channel { - id: channel_b_id, - name: "channel-b".to_string(), - parent_id: Some(channel_a_id), - depth: 1, - }) - ] - ); - assert!(!channels.is_user_admin(channel_a_id)); - assert!(!channels.is_user_admin(channel_b_id)); - }); + assert_channel_invitations(client_b.channel_store(), cx_b, &[]); + assert_channels( + client_b.channel_store(), + cx_b, + &[ + ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + user_is_admin: false, + depth: 0, + }, + ExpectedChannel { + id: channel_b_id, + name: "channel-b".to_string(), + user_is_admin: false, + depth: 1, + }, + ], + ); let channel_c_id = client_a .channel_store() @@ -157,31 +150,30 @@ async fn test_core_channels( .unwrap(); deterministic.run_until_parked(); - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channels(), - &[ - Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - }), - Arc::new(Channel { - id: channel_b_id, - name: "channel-b".to_string(), - parent_id: Some(channel_a_id), - depth: 1, - }), - Arc::new(Channel { - id: channel_c_id, - name: "channel-c".to_string(), - parent_id: Some(channel_b_id), - depth: 2, - }), - ] - ) - }); + assert_channels( + client_b.channel_store(), + cx_b, + &[ + ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + user_is_admin: false, + depth: 0, + }, + ExpectedChannel { + id: channel_b_id, + name: "channel-b".to_string(), + user_is_admin: false, + depth: 1, + }, + ExpectedChannel { + id: channel_c_id, + name: "channel-c".to_string(), + user_is_admin: false, + depth: 2, + }, + ], + ); // Update client B's membership to channel A to be an admin. client_a @@ -195,34 +187,31 @@ async fn test_core_channels( // Observe that client B is now an admin of channel A, and that // their admin priveleges extend to subchannels of channel A. - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!(channels.channel_invitations(), &[]); - assert_eq!( - channels.channels(), - &[ - Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - }), - Arc::new(Channel { - id: channel_b_id, - name: "channel-b".to_string(), - parent_id: Some(channel_a_id), - depth: 1, - }), - Arc::new(Channel { - id: channel_c_id, - name: "channel-c".to_string(), - parent_id: Some(channel_b_id), - depth: 2, - }), - ] - ); - - assert!(channels.is_user_admin(channel_c_id)) - }); + assert_channel_invitations(client_b.channel_store(), cx_b, &[]); + assert_channels( + client_b.channel_store(), + cx_b, + &[ + ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: true, + }, + ExpectedChannel { + id: channel_b_id, + name: "channel-b".to_string(), + depth: 1, + user_is_admin: true, + }, + ExpectedChannel { + id: channel_c_id, + name: "channel-c".to_string(), + depth: 2, + user_is_admin: true, + }, + ], + ); // Client A deletes the channel, deletion also deletes subchannels. client_a @@ -234,30 +223,26 @@ async fn test_core_channels( .unwrap(); deterministic.run_until_parked(); - client_a.channel_store().read_with(cx_a, |channels, _| { - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - - depth: 0, - })] - ) - }); - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - - depth: 0, - })] - ) - }); + assert_channels( + client_a.channel_store(), + cx_a, + &[ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: true, + }], + ); + assert_channels( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: true, + }], + ); // Remove client B client_a @@ -271,46 +256,38 @@ async fn test_core_channels( deterministic.run_until_parked(); // Client A still has their channel - client_a.channel_store().read_with(cx_a, |channels, _| { - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - })] - ) - }); + assert_channels( + client_a.channel_store(), + cx_a, + &[ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: true, + }], + ); - // Client B is gone - client_b - .channel_store() - .read_with(cx_b, |channels, _| assert_eq!(channels.channels(), &[])); + // Client B no longer has access to the channel + assert_channels(client_b.channel_store(), cx_b, &[]); // When disconnected, client A sees no channels. server.forbid_connections(); server.disconnect_client(client_a.peer_id().unwrap()); deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); - client_a.channel_store().read_with(cx_a, |channels, _| { - assert_eq!(channels.channels(), &[]); - assert!(!channels.is_user_admin(channel_a_id)); - }); + assert_channels(client_a.channel_store(), cx_a, &[]); server.allow_connections(); deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); - client_a.channel_store().read_with(cx_a, |channels, _| { - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: channel_a_id, - name: "channel-a".to_string(), - parent_id: None, - depth: 0, - })] - ); - assert!(channels.is_user_admin(channel_a_id)); - }); + assert_channels( + client_a.channel_store(), + cx_a, + &[ExpectedChannel { + id: channel_a_id, + name: "channel-a".to_string(), + depth: 0, + user_is_admin: true, + }], + ); } fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u64]) { @@ -404,20 +381,21 @@ async fn test_channel_room( ); }); + assert_channels( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + id: zed_id, + name: "zed".to_string(), + depth: 0, + user_is_admin: false, + }], + ); client_b.channel_store().read_with(cx_b, |channels, _| { assert_participants_eq( channels.channel_participants(zed_id), &[client_a.user_id().unwrap()], ); - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: zed_id, - name: "zed".to_string(), - parent_id: None, - depth: 0, - })] - ) }); client_c.channel_store().read_with(cx_c, |channels, _| { @@ -629,20 +607,17 @@ async fn test_permissions_update_while_invited( deterministic.run_until_parked(); - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channel_invitations(), - &[Arc::new(Channel { - id: rust_id, - name: "rust".to_string(), - parent_id: None, - - depth: 0, - })], - ); - - assert_eq!(channels.channels(), &[],); - }); + assert_channel_invitations( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + depth: 0, + id: rust_id, + name: "rust".to_string(), + user_is_admin: false, + }], + ); + assert_channels(client_b.channel_store(), cx_b, &[]); // Update B's invite before they've accepted it client_a @@ -655,20 +630,17 @@ async fn test_permissions_update_while_invited( deterministic.run_until_parked(); - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channel_invitations(), - &[Arc::new(Channel { - id: rust_id, - name: "rust".to_string(), - parent_id: None, - - depth: 0, - })], - ); - - assert_eq!(channels.channels(), &[],); - }); + assert_channel_invitations( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + depth: 0, + id: rust_id, + name: "rust".to_string(), + user_is_admin: false, + }], + ); + assert_channels(client_b.channel_store(), cx_b, &[]); } #[gpui::test] @@ -695,34 +667,78 @@ async fn test_channel_rename( .await .unwrap(); - let rust_archive_id = rust_id; deterministic.run_until_parked(); // Client A sees the channel with its new name. - client_a.channel_store().read_with(cx_a, |channels, _| { - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: rust_archive_id, - name: "rust-archive".to_string(), - parent_id: None, - - depth: 0, - })], - ); - }); + assert_channels( + client_a.channel_store(), + cx_a, + &[ExpectedChannel { + depth: 0, + id: rust_id, + name: "rust-archive".to_string(), + user_is_admin: true, + }], + ); // Client B sees the channel with its new name. - client_b.channel_store().read_with(cx_b, |channels, _| { - assert_eq!( - channels.channels(), - &[Arc::new(Channel { - id: rust_archive_id, - name: "rust-archive".to_string(), - parent_id: None, + assert_channels( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + depth: 0, + id: rust_id, + name: "rust-archive".to_string(), + user_is_admin: false, + }], + ); +} + +#[derive(Debug, PartialEq)] +struct ExpectedChannel { + depth: usize, + id: ChannelId, + name: String, + user_is_admin: bool, +} +#[track_caller] +fn assert_channel_invitations( + channel_store: &ModelHandle, + cx: &TestAppContext, + expected_channels: &[ExpectedChannel], +) { + let actual = channel_store.read_with(cx, |store, _| { + store + .channel_invitations() + .iter() + .map(|channel| ExpectedChannel { depth: 0, - })], - ); + name: channel.name.clone(), + id: channel.id, + user_is_admin: store.is_user_admin(channel.id), + }) + .collect::>() + }); + assert_eq!(actual, expected_channels); +} + +#[track_caller] +fn assert_channels( + channel_store: &ModelHandle, + cx: &TestAppContext, + expected_channels: &[ExpectedChannel], +) { + let actual = channel_store.read_with(cx, |store, _| { + store + .channels() + .map(|(depth, channel)| ExpectedChannel { + depth, + name: channel.name.clone(), + id: channel.id, + user_is_admin: store.is_user_admin(channel.id), + }) + .collect::>() }); + assert_eq!(actual, expected_channels); } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 8c63649ef9b41a1b4794ce75ce364f76a85ee3cd..563cc942da1ad924a67c8eb304a8a681ff34d3df 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -194,7 +194,10 @@ enum ListEntry { IncomingRequest(Arc), OutgoingRequest(Arc), ChannelInvite(Arc), - Channel(Arc), + Channel { + channel: Arc, + depth: usize, + }, ChannelEditor { depth: usize, }, @@ -315,9 +318,10 @@ impl CollabPanel { cx, ) } - ListEntry::Channel(channel) => { + ListEntry::Channel { channel, depth } => { let channel_row = this.render_channel( &*channel, + *depth, &theme.collab_panel, is_selected, cx, @@ -438,7 +442,7 @@ impl CollabPanel { if this.take_editing_state(cx) { this.update_entries(false, cx); this.selection = this.entries.iter().position(|entry| { - if let ListEntry::Channel(channel) = entry { + if let ListEntry::Channel { channel, .. } = entry { channel.id == *channel_id } else { false @@ -621,17 +625,19 @@ impl CollabPanel { if self.include_channels_section(cx) { self.entries.push(ListEntry::Header(Section::Channels, 0)); - let channels = channel_store.channels(); - if !(channels.is_empty() && self.channel_editing_state.is_none()) { + if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { self.match_candidates.clear(); self.match_candidates - .extend(channels.iter().enumerate().map(|(ix, channel)| { - StringMatchCandidate { - id: ix, - string: channel.name.clone(), - char_bag: channel.name.chars().collect(), - } - })); + .extend( + channel_store + .channels() + .enumerate() + .map(|(ix, (_, channel))| StringMatchCandidate { + id: ix, + string: channel.name.clone(), + char_bag: channel.name.chars().collect(), + }), + ); let matches = executor.block(match_strings( &self.match_candidates, &query, @@ -652,26 +658,30 @@ impl CollabPanel { } } for mat in matches { - let channel = &channels[mat.candidate_id]; + let (depth, channel) = + channel_store.channel_at_index(mat.candidate_id).unwrap(); match &self.channel_editing_state { Some(ChannelEditingState::Create { parent_id, .. }) if *parent_id == Some(channel.id) => { - self.entries.push(ListEntry::Channel(channel.clone())); - self.entries.push(ListEntry::ChannelEditor { - depth: channel.depth + 1, + self.entries.push(ListEntry::Channel { + channel: channel.clone(), + depth, }); + self.entries + .push(ListEntry::ChannelEditor { depth: depth + 1 }); } Some(ChannelEditingState::Rename { channel_id, .. }) if *channel_id == channel.id => { - self.entries.push(ListEntry::ChannelEditor { - depth: channel.depth, - }); + self.entries.push(ListEntry::ChannelEditor { depth }); } _ => { - self.entries.push(ListEntry::Channel(channel.clone())); + self.entries.push(ListEntry::Channel { + channel: channel.clone(), + depth, + }); } } } @@ -1497,6 +1507,7 @@ impl CollabPanel { fn render_channel( &self, channel: &Channel, + depth: usize, theme: &theme::CollabPanel, is_selected: bool, cx: &mut ViewContext, @@ -1542,7 +1553,7 @@ impl CollabPanel { .with_style(*theme.contact_row.style_for(is_selected, state)) .with_padding_left( theme.contact_row.default_style().padding.left - + theme.channel_indent * channel.depth as f32, + + theme.channel_indent * depth as f32, ) }) .on_click(MouseButton::Left, move |_, this, cx| { @@ -1884,7 +1895,7 @@ impl CollabPanel { }); } } - ListEntry::Channel(channel) => { + ListEntry::Channel { channel, .. } => { self.join_channel(channel.id, cx); } ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx), @@ -2031,7 +2042,7 @@ impl CollabPanel { if !channel_store.is_user_admin(action.channel_id) { return; } - if let Some(channel) = channel_store.channel_for_id(action.channel_id) { + if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() { self.channel_editing_state = Some(ChannelEditingState::Rename { channel_id: action.channel_id, pending_name: None, @@ -2058,7 +2069,7 @@ impl CollabPanel { self.selection .and_then(|ix| self.entries.get(ix)) .and_then(|entry| match entry { - ListEntry::Channel(channel) => Some(channel), + ListEntry::Channel { channel, .. } => Some(channel), _ => None, }) } @@ -2395,9 +2406,16 @@ impl PartialEq for ListEntry { return peer_id_1 == peer_id_2; } } - ListEntry::Channel(channel_1) => { - if let ListEntry::Channel(channel_2) = other { - return channel_1.id == channel_2.id; + ListEntry::Channel { + channel: channel_1, + depth: depth_1, + } => { + if let ListEntry::Channel { + channel: channel_2, + depth: depth_2, + } = other + { + return channel_1.id == channel_2.id && depth_1 == depth_2; } } ListEntry::ChannelInvite(channel_1) => { From 5af8ee71aa9d35d594f4777f2049f3c8a70a7dbd Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 16:38:21 -0700 Subject: [PATCH 084/105] Fix clicking outside of modals to dismiss them Co-authored-by: Mikayla --- crates/workspace/src/workspace.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index f875c71fe6f7bbfd2545539a5a85931d5e4cc9fa..60488d04cf8d5651276fdd8133c7e9a7b2a79759 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3760,20 +3760,19 @@ impl View for Workspace { ) })) .with_children(self.modal.as_ref().map(|modal| { + // Prevent clicks within the modal from falling + // through to the rest of the workspace. enum ModalBackground {} MouseEventHandler::::new( 0, cx, - |_, cx| { - ChildView::new(modal.view.as_any(), cx) - .contained() - .with_style(theme.workspace.modal) - .aligned() - .top() - }, + |_, cx| ChildView::new(modal.view.as_any(), cx), ) .on_click(MouseButton::Left, |_, _, _| {}) - // Consume click events to stop focus dropping through + .contained() + .with_style(theme.workspace.modal) + .aligned() + .top() })) .with_children(self.render_notifications(&theme.workspace, cx)), )) From 13982fe2f4a47fe75a98360f2d70cd9bea9af53d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 16:47:26 -0700 Subject: [PATCH 085/105] Display intended mute status while still connecting to a room Co-authored-by: Mikayla --- crates/call/src/room.rs | 6 +++--- crates/collab_ui/src/collab_titlebar_item.rs | 4 ++-- crates/collab_ui/src/collab_ui.rs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 683ff6f4df3b024b381f6f8f979953ce1fddb2a4..5a4bc8329f6941404a2c723234c875701fba5a3f 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -1116,11 +1116,11 @@ impl Room { }) } - pub fn is_muted(&self) -> bool { + pub fn is_muted(&self, cx: &AppContext) -> bool { self.live_kit .as_ref() .and_then(|live_kit| match &live_kit.microphone_track { - LocalTrack::None => Some(true), + LocalTrack::None => Some(settings::get::(cx).mute_on_join), LocalTrack::Pending { muted, .. } => Some(*muted), LocalTrack::Published { muted, .. } => Some(*muted), }) @@ -1310,7 +1310,7 @@ impl Room { } pub fn toggle_mute(&mut self, cx: &mut ModelContext) -> Result>> { - let should_mute = !self.is_muted(); + let should_mute = !self.is_muted(cx); if let Some(live_kit) = self.live_kit.as_mut() { if matches!(live_kit.microphone_track, LocalTrack::None) { return Ok(self.share_microphone(cx)); diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 97881f9a50b626a4ff2a4ca2bf0b76e41c4c7522..22f294d3fc17fc60c564b82eab1a8c7aee9c8589 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -90,7 +90,7 @@ impl View for CollabTitlebarItem { right_container .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx)); right_container.add_child(self.render_leave_call(&theme, cx)); - let muted = room.read(cx).is_muted(); + let muted = room.read(cx).is_muted(cx); let speaking = room.read(cx).is_speaking(); left_container.add_child( self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx), @@ -544,7 +544,7 @@ impl CollabTitlebarItem { ) -> AnyElement { let icon; let tooltip; - let is_muted = room.read(cx).is_muted(); + let is_muted = room.read(cx).is_muted(cx); if is_muted { icon = "icons/radix/mic-mute.svg"; tooltip = "Unmute microphone"; diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 1e48026f466225439585b168cd9d9b79410de796..f2ba35967fe5225b35e0c5f315ad507ec9ae7197 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -64,7 +64,7 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { if let Some(room) = call.room().cloned() { let client = call.client(); room.update(cx, |room, cx| { - if room.is_muted() { + if room.is_muted(cx) { ActiveCall::report_call_event_for_room("enable microphone", room.id(), &client, cx); } else { ActiveCall::report_call_event_for_room( From 71454ba27cfa2135cf20e403fd87def27ebca408 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 17:11:03 -0700 Subject: [PATCH 086/105] Limit number of participants shown in channel face piles Co-authored-by: Mikayla --- crates/collab_ui/src/collab_panel.rs | 45 +++++++++++++++++++-------- crates/collab_ui/src/face_pile.rs | 1 + crates/theme/src/theme.rs | 1 + styles/src/style_tree/collab_panel.ts | 9 ++++++ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 563cc942da1ad924a67c8eb304a8a681ff34d3df..e4838df93927370a03ccd55fd4384aa246ef0baa 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1514,6 +1514,8 @@ impl CollabPanel { ) -> AnyElement { let channel_id = channel.id; + const FACEPILE_LIMIT: usize = 4; + MouseEventHandler::::new(channel.id as usize, cx, |state, cx| { Flex::row() .with_child( @@ -1532,20 +1534,37 @@ impl CollabPanel { .left() .flex(1., true), ) - .with_child( - FacePile::new(theme.face_overlap).with_children( - self.channel_store - .read(cx) - .channel_participants(channel_id) - .iter() - .filter_map(|user| { - Some( - Image::from_data(user.avatar.clone()?) - .with_style(theme.contact_avatar), + .with_children({ + let participants = self.channel_store.read(cx).channel_participants(channel_id); + if !participants.is_empty() { + let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT); + + Some( + FacePile::new(theme.face_overlap) + .with_children( + participants + .iter() + .filter_map(|user| { + Some( + Image::from_data(user.avatar.clone()?) + .with_style(theme.contact_avatar), + ) + }) + .take(FACEPILE_LIMIT), ) - }), - ), - ) + .with_children((extra_count > 0).then(|| { + Label::new( + format!("+{}", extra_count), + theme.extra_participant_label.text.clone(), + ) + .contained() + .with_style(theme.extra_participant_label.container) + })), + ) + } else { + None + } + }) .align_children_center() .constrained() .with_height(theme.row_height) diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs index ba9b61c98b80f111e8385d9ee3e57d76e41ea1de..a86b2576869b5d2e3b695ae41d073bf6fa631812 100644 --- a/crates/collab_ui/src/face_pile.rs +++ b/crates/collab_ui/src/face_pile.rs @@ -68,6 +68,7 @@ impl Element for FacePile { 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; scene.paint_layer(None, |scene| { face.paint(scene, vec2f(origin_x, origin_y), visible_bounds, view, cx); }); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 4919eb93c77c18e2cf214b5569d701031da9fef5..e081b700472d33f791e9c558d1e3885b79197057 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -241,6 +241,7 @@ pub struct CollabPanel { pub project_row: Toggleable>, pub tree_branch: Toggleable>, pub contact_avatar: ImageStyle, + pub extra_participant_label: ContainedText, pub contact_status_free: ContainerStyle, pub contact_status_busy: ContainerStyle, pub contact_username: ContainedText, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 6cf6f9b09525a84a29aba778aaddf8ec75309e85..1d1e09075e24c78c27bbbb4b7a7efbce6ae5de9e 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -245,6 +245,15 @@ export default function contacts_panel(): any { corner_radius: 10, width: 20, }, + extra_participant_label: { + corner_radius: 10, + padding: { + left: 10, + right: 4, + }, + background: background(layer, "hovered"), + ...text(layer, "ui_sans", "hovered", { size: "xs" }) + }, contact_status_free: indicator({ layer, color: "positive" }), contact_status_busy: indicator({ layer, color: "negative" }), contact_username: { From cbf497bc12dee7227ade37c410993deafba69595 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 14 Aug 2023 17:36:35 -0700 Subject: [PATCH 087/105] Fix race condition when UpdateChannel message is received while fetching participants for previous update Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 45 ++++++++++++++++++------ crates/client/src/channel_store_tests.rs | 3 +- crates/collab/src/rpc.rs | 5 --- crates/collab/src/tests/channel_tests.rs | 2 ++ crates/collab_ui/src/collab_panel.rs | 2 -- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 8217e6cbc8bb7432b5c5eba284ca24118a77bc0b..e2c18a63a96cf38768c68fc67b856536a53c2d18 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -4,11 +4,13 @@ use anyhow::anyhow; use anyhow::Result; use collections::HashMap; use collections::HashSet; +use futures::channel::mpsc; use futures::Future; use futures::StreamExt; use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, TypedEnvelope}; use std::sync::Arc; +use util::ResultExt; pub type ChannelId = u64; pub type UserId = u64; @@ -20,10 +22,12 @@ pub struct ChannelStore { channel_participants: HashMap>>, channels_with_admin_privileges: HashSet, outgoing_invites: HashSet<(ChannelId, UserId)>, + update_channels_tx: mpsc::UnboundedSender, client: Arc, user_store: ModelHandle, _rpc_subscription: Subscription, _watch_connection_status: Task<()>, + _update_channels: Task<()>, } #[derive(Clone, Debug, PartialEq)] @@ -62,6 +66,7 @@ impl ChannelStore { let rpc_subscription = client.add_message_handler(cx.handle(), Self::handle_update_channels); + let (update_channels_tx, mut update_channels_rx) = mpsc::unbounded(); let mut connection_status = client.status(); let watch_connection_status = cx.spawn_weak(|this, mut cx| async move { while let Some(status) = connection_status.next().await { @@ -89,10 +94,23 @@ impl ChannelStore { channel_participants: Default::default(), channels_with_admin_privileges: Default::default(), outgoing_invites: Default::default(), + update_channels_tx, client, user_store, _rpc_subscription: rpc_subscription, _watch_connection_status: watch_connection_status, + _update_channels: cx.spawn_weak(|this, mut cx| async move { + while let Some(update_channels) = update_channels_rx.next().await { + if let Some(this) = this.upgrade(&cx) { + let update_task = this.update(&mut cx, |this, cx| { + this.update_channels(update_channels, cx) + }); + if let Some(update_task) = update_task { + update_task.await.log_err(); + } + } + } + }), } } @@ -159,13 +177,14 @@ impl ChannelStore { let channel_id = channel.id; this.update(&mut cx, |this, cx| { - this.update_channels( + let task = this.update_channels( proto::UpdateChannels { channels: vec![channel], ..Default::default() }, cx, ); + assert!(task.is_none()); // This event is emitted because the collab panel wants to clear the pending edit state // before this frame is rendered. But we can't guarantee that the collab panel's future @@ -287,13 +306,14 @@ impl ChannelStore { .channel .ok_or_else(|| anyhow!("missing channel in response"))?; this.update(&mut cx, |this, cx| { - this.update_channels( + let task = this.update_channels( proto::UpdateChannels { channels: vec![channel], ..Default::default() }, cx, ); + assert!(task.is_none()); // This event is emitted because the collab panel wants to clear the pending edit state // before this frame is rendered. But we can't guarantee that the collab panel's future @@ -375,8 +395,10 @@ impl ChannelStore { _: Arc, mut cx: AsyncAppContext, ) -> Result<()> { - this.update(&mut cx, |this, cx| { - this.update_channels(message.payload, cx); + this.update(&mut cx, |this, _| { + this.update_channels_tx + .unbounded_send(message.payload) + .unwrap(); }); Ok(()) } @@ -385,7 +407,7 @@ impl ChannelStore { &mut self, payload: proto::UpdateChannels, cx: &mut ModelContext, - ) { + ) -> Option>> { if !payload.remove_channel_invitations.is_empty() { self.channel_invitations .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); @@ -470,6 +492,11 @@ impl ChannelStore { } } + cx.notify(); + if payload.channel_participants.is_empty() { + return None; + } + let mut all_user_ids = Vec::new(); let channel_participants = payload.channel_participants; for entry in &channel_participants { @@ -480,11 +507,10 @@ impl ChannelStore { } } - // TODO: Race condition if an update channels messages comes in while resolving avatars let users = self .user_store .update(cx, |user_store, cx| user_store.get_users(all_user_ids, cx)); - cx.spawn(|this, mut cx| async move { + Some(cx.spawn(|this, mut cx| async move { let users = users.await?; this.update(&mut cx, |this, cx| { @@ -509,10 +535,7 @@ impl ChannelStore { cx.notify(); }); anyhow::Ok(()) - }) - .detach(); - - cx.notify(); + })) } fn channel_path_sorting_key<'a>( diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs index 3a3f3842eb5ea0a37bc737715929a543a9a2987f..51e819349e7c665976d4ff0af5f20c7bb32eaff2 100644 --- a/crates/client/src/channel_store_tests.rs +++ b/crates/client/src/channel_store_tests.rs @@ -139,7 +139,8 @@ fn update_channels( message: proto::UpdateChannels, cx: &mut AppContext, ) { - channel_store.update(cx, |store, cx| store.update_channels(message, cx)); + let task = channel_store.update(cx, |store, cx| store.update_channels(message, cx)); + assert!(task.is_none()); } #[track_caller] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index f9f2d4a2e24109354fbf363e3acb11fb711f0d63..2396085a011ff4bf737003cb19f6d1535fa1dc4b 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -1156,7 +1156,6 @@ async fn rejoin_room( channel_members = mem::take(&mut rejoined_room.channel_members); } - //TODO: move this into the room guard if let Some(channel_id) = channel_id { channel_updated( channel_id, @@ -2453,9 +2452,6 @@ async fn join_channel( joined_room.clone() }; - // TODO - do this while still holding the room guard, - // currently there's a possible race condition if someone joins the channel - // after we've dropped the lock but before we finish sending these updates channel_updated( channel_id, &joined_room.room, @@ -2748,7 +2744,6 @@ async fn leave_room_for_session(session: &Session) -> Result<()> { return Ok(()); } - // TODO - do this while holding the room guard. if let Some(channel_id) = channel_id { channel_updated( channel_id, diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index f1157ce7ae8ac0fc5b2e95d38035dbe434c56300..d4cf6423f0287f6fd184d6db7a654eb810580267 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -290,6 +290,7 @@ async fn test_core_channels( ); } +#[track_caller] fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u64]) { assert_eq!( participants.iter().map(|p| p.id).collect::>(), @@ -297,6 +298,7 @@ fn assert_participants_eq(participants: &[Arc], expected_partitipants: &[u ); } +#[track_caller] fn assert_members_eq( members: &[ChannelMembership], expected_members: &[(u64, bool, proto::channel_member::Kind)], diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index e4838df93927370a03ccd55fd4384aa246ef0baa..eaa3560b9be9a9aef31174482de35911f00957d7 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2037,8 +2037,6 @@ impl CollabPanel { self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx); } - // TODO: Make join into a toggle - // TODO: Make enter work on channel editor fn remove(&mut self, _: &Remove, cx: &mut ViewContext) { if let Some(channel) = self.selected_channel() { self.remove_channel(channel.id, cx) From 9e99b74fceea361b2dfc63b91f92133f33b1b564 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 15 Aug 2023 10:45:03 -0700 Subject: [PATCH 088/105] Add the channel name into the current call --- crates/collab_ui/src/collab_panel.rs | 40 +++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 2cba7291117ed7549f873a0fff9c566b86b6ca1d..665779fb98af6d9c22e34dcabc4094d45003d064 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -34,9 +34,9 @@ use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; use staff_mode::StaffMode; -use std::{mem, sync::Arc}; +use std::{borrow::Cow, mem, sync::Arc}; use theme::IconButton; -use util::{ResultExt, TryFutureExt}; +use util::{iife, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, item::ItemHandle, @@ -1181,13 +1181,35 @@ impl CollabPanel { let tooltip_style = &theme.tooltip; let text = match section { - Section::ActiveCall => "Current Call", - Section::ContactRequests => "Requests", - Section::Contacts => "Contacts", - Section::Channels => "Channels", - Section::ChannelInvites => "Invites", - Section::Online => "Online", - Section::Offline => "Offline", + Section::ActiveCall => { + let channel_name = iife!({ + let channel_id = ActiveCall::global(cx) + .read(cx) + .room()? + .read(cx) + .channel_id()?; + let name = self + .channel_store + .read(cx) + .channel_for_id(channel_id)? + .name + .as_str(); + + Some(name) + }); + + if let Some(name) = channel_name { + Cow::Owned(format!("Current Call - #{}", name)) + } else { + Cow::Borrowed("Current Call") + } + } + Section::ContactRequests => Cow::Borrowed("Requests"), + Section::Contacts => Cow::Borrowed("Contacts"), + Section::Channels => Cow::Borrowed("Channels"), + Section::ChannelInvites => Cow::Borrowed("Invites"), + Section::Online => Cow::Borrowed("Online"), + Section::Offline => Cow::Borrowed("Offline"), }; enum AddContact {} From e36dfa09462029e800b1ec469c241140ce071b90 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 15 Aug 2023 10:53:30 -0700 Subject: [PATCH 089/105] Add active styling --- crates/collab_ui/src/collab_panel.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 665779fb98af6d9c22e34dcabc4094d45003d064..0665ecf75b2e7a0bbbc84468420028978baf8dc3 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1535,6 +1535,15 @@ impl CollabPanel { cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; + let is_active = iife!({ + let call_channel = ActiveCall::global(cx) + .read(cx) + .room()? + .read(cx) + .channel_id()?; + Some(call_channel == channel_id) + }) + .unwrap_or(false); const FACEPILE_LIMIT: usize = 4; @@ -1591,7 +1600,7 @@ impl CollabPanel { .constrained() .with_height(theme.row_height) .contained() - .with_style(*theme.contact_row.style_for(is_selected, state)) + .with_style(*theme.contact_row.style_for(is_selected || is_active, state)) .with_padding_left( theme.contact_row.default_style().padding.left + theme.channel_indent * depth as f32, From d95b036fde3e3b35ba6c52b7f97fdb7719b30a28 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 15 Aug 2023 10:58:24 -0700 Subject: [PATCH 090/105] Fix cursor style co-authored-by: Nate --- crates/collab_ui/src/collab_panel.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 0665ecf75b2e7a0bbbc84468420028978baf8dc3..2b79f5a125d006cf8d724760a6fa24a6538b1096 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1283,7 +1283,7 @@ impl CollabPanel { let can_collapse = depth > 0; let icon_size = (&theme.collab_panel).section_icon_size; - MouseEventHandler::new::(section as usize, cx, |state, _| { + let mut result = MouseEventHandler::new::(section as usize, cx, |state, _| { let header_style = if can_collapse { theme .collab_panel @@ -1328,14 +1328,19 @@ impl CollabPanel { .with_height(theme.collab_panel.row_height) .contained() .with_style(header_style.container) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - if can_collapse { - this.toggle_expanded(section, cx); - } - }) - .into_any() + }); + + if can_collapse { + result = result + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + if can_collapse { + this.toggle_expanded(section, cx); + } + }) + } + + result.into_any() } fn render_contact( @@ -1612,6 +1617,7 @@ impl CollabPanel { .on_click(MouseButton::Right, move |e, this, cx| { this.deploy_channel_context_menu(Some(e.position), channel_id, cx); }) + .with_cursor_style(CursorStyle::PointingHand) .into_any() } From d05e8852d300d54505cac0383759e0422f3a67e0 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 15 Aug 2023 11:02:18 -0700 Subject: [PATCH 091/105] Add dismiss on escape --- crates/collab_ui/src/collab_panel/channel_modal.rs | 5 +++++ crates/collab_ui/src/collab_panel/contact_finder.rs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 48d3a7a0ec11b05024e39271500c1467b92a256c..3e4f274f234494e47660d9b7b51aadb6b7f6a4ca 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -27,6 +27,7 @@ pub fn init(cx: &mut AppContext) { 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 { @@ -131,6 +132,10 @@ impl ChannelModal { picker.delegate_mut().remove_selected_member(cx); }); } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + cx.emit(PickerEvent::Dismiss); + } } impl Entity for ChannelModal { diff --git a/crates/collab_ui/src/collab_panel/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs index 4cc7034f49a46607bd28a3d4eff5b75503625d36..539e041ae728ca2770076d1e8849af96a44fefe3 100644 --- a/crates/collab_ui/src/collab_panel/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -9,6 +9,7 @@ use workspace::Modal; pub fn init(cx: &mut AppContext) { Picker::::init(cx); + cx.add_action(ContactFinder::dismiss) } pub struct ContactFinder { @@ -43,6 +44,10 @@ impl ContactFinder { picker.set_query(query, cx); }); } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + cx.emit(PickerEvent::Dismiss); + } } impl Entity for ContactFinder { From d13cedb248f7f3e5d577638714b70a714f940960 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 15 Aug 2023 12:12:30 -0700 Subject: [PATCH 092/105] seperate out channel styles in theme --- crates/collab_ui/src/collab_panel.rs | 12 ++++---- crates/theme/src/theme.rs | 3 ++ styles/src/style_tree/collab_panel.ts | 43 +++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 2b79f5a125d006cf8d724760a6fa24a6538b1096..3f303da2af1bf9c5b7ffa2e7ba9e2214219017f2 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1550,7 +1550,7 @@ impl CollabPanel { }) .unwrap_or(false); - const FACEPILE_LIMIT: usize = 4; + const FACEPILE_LIMIT: usize = 3; MouseEventHandler::new::(channel.id as usize, cx, |state, cx| { Flex::row() @@ -1563,9 +1563,9 @@ impl CollabPanel { .left(), ) .with_child( - Label::new(channel.name.clone(), theme.contact_username.text.clone()) + Label::new(channel.name.clone(), theme.channel_name.text.clone()) .contained() - .with_style(theme.contact_username.container) + .with_style(theme.channel_name.container) .aligned() .left() .flex(1., true), @@ -1583,7 +1583,7 @@ impl CollabPanel { .filter_map(|user| { Some( Image::from_data(user.avatar.clone()?) - .with_style(theme.contact_avatar), + .with_style(theme.channel_avatar), ) }) .take(FACEPILE_LIMIT), @@ -1605,9 +1605,9 @@ impl CollabPanel { .constrained() .with_height(theme.row_height) .contained() - .with_style(*theme.contact_row.style_for(is_selected || is_active, state)) + .with_style(*theme.channel_row.style_for(is_selected || is_active, state)) .with_padding_left( - theme.contact_row.default_style().padding.left + theme.channel_row.default_style().padding.left + theme.channel_indent * depth as f32, ) }) diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index e081b700472d33f791e9c558d1e3885b79197057..912ca0e8b87bc5b55307d3a9f549341bc4bdf80a 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -237,10 +237,13 @@ pub struct CollabPanel { pub subheader_row: Toggleable>, pub leave_call: Interactive, pub contact_row: Toggleable>, + pub channel_row: Toggleable>, + pub channel_name: ContainedText, pub row_height: f32, pub project_row: Toggleable>, pub tree_branch: Toggleable>, pub contact_avatar: ImageStyle, + pub channel_avatar: ImageStyle, pub extra_participant_label: ContainedText, pub contact_status_free: ContainerStyle, pub contact_status_busy: ContainerStyle, diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 1d1e09075e24c78c27bbbb4b7a7efbce6ae5de9e..c65887e17cdf8f0022f5054919eecccfcb115b35 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -118,6 +118,38 @@ export default function contacts_panel(): any { }, } + const item_row = toggleable({ + base: interactive({ + base: { + padding: { + left: SPACING, + right: SPACING, + }, + }, + state: { + clicked: { + background: background(layer, "pressed"), + }, + }, + }), + state: { + inactive: { + hovered: { + background: background(layer, "hovered"), + }, + }, + active: { + default: { + ...text(layer, "ui_sans", "active", { size: "sm" }), + background: background(layer, "active"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }, + }) + return { ...collab_modals(), log_in_button: text_button(), @@ -198,6 +230,13 @@ export default function contacts_panel(): any { }, }, }), + channel_row: item_row, + channel_name: { + ...text(layer, "ui_sans", { size: "sm" }), + margin: { + left: NAME_MARGIN, + }, + }, list_empty_label_container: { margin: { left: NAME_MARGIN, @@ -245,6 +284,10 @@ export default function contacts_panel(): any { corner_radius: 10, width: 20, }, + channel_avatar: { + corner_radius: 10, + width: 20, + }, extra_participant_label: { corner_radius: 10, padding: { From 9d60e550bed8b6b20cbe57943f81d814b5272149 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Tue, 15 Aug 2023 15:32:14 -0400 Subject: [PATCH 093/105] Additional status bar styles --- assets/icons/check.svg | 3 +++ assets/icons/check_circle.svg | 4 ++++ crates/diagnostics/src/items.rs | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 assets/icons/check.svg create mode 100644 assets/icons/check_circle.svg diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000000000000000000000000000000000000..77b180892cfaf3b230cee3b4d9303a0fefac6e06 --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_circle.svg b/assets/icons/check_circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..85ba2e1f37724edc819c58bbdf3009ca491f760d --- /dev/null +++ b/assets/icons/check_circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index d1a32c72f10a4e23fc5d2c8ecc08b263cebf7e34..89b4469d42d0f795f27db338cb2e84eb224431d8 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -105,7 +105,7 @@ impl View for DiagnosticIndicator { let mut summary_row = Flex::row(); if self.summary.error_count > 0 { summary_row.add_child( - Svg::new("icons/circle_x_mark_16.svg") + Svg::new("icons/error.svg") .with_color(style.icon_color_error) .constrained() .with_width(style.icon_width) @@ -121,7 +121,7 @@ impl View for DiagnosticIndicator { if self.summary.warning_count > 0 { summary_row.add_child( - Svg::new("icons/triangle_exclamation_16.svg") + Svg::new("icons/warning.svg") .with_color(style.icon_color_warning) .constrained() .with_width(style.icon_width) @@ -142,7 +142,7 @@ impl View for DiagnosticIndicator { if self.summary.error_count == 0 && self.summary.warning_count == 0 { summary_row.add_child( - Svg::new("icons/circle_check_16.svg") + Svg::new("icons/check_circle.svg") .with_color(style.icon_color_ok) .constrained() .with_width(style.icon_width) From 46928fa871aae366ecff130cf12fd6867e66c5ca Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 15 Aug 2023 13:08:44 -0700 Subject: [PATCH 094/105] Reword channel-creation tooltips --- crates/collab_ui/src/collab_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 3f303da2af1bf9c5b7ffa2e7ba9e2214219017f2..7ad7a8883d8c3c47dc43bb36447ea4e4bc80a37a 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1272,7 +1272,7 @@ impl CollabPanel { .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx)) .with_tooltip::( 0, - "Add or join a channel", + "Create a channel", None, tooltip_style.clone(), cx, @@ -1836,7 +1836,7 @@ impl CollabPanel { gpui::elements::AnchorCorner::BottomLeft }, vec![ - ContextMenuItem::action("New Channel", NewChannel { channel_id }), + ContextMenuItem::action("New Subchannel", NewChannel { channel_id }), ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), ContextMenuItem::action("Manage members", ManageMembers { channel_id }), ContextMenuItem::action("Invite members", InviteMembers { channel_id }), From ddf3642d47d3fcf2ad6e7abc40d0272387cb8bf1 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 15 Aug 2023 13:18:56 -0700 Subject: [PATCH 095/105] Avoid flicker when moving between channels --- crates/call/src/call.rs | 6 +----- crates/call/src/room.rs | 19 +++++++++++-------- crates/collab/src/rpc.rs | 1 + 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 6e58be4f153b04b1df4a313c0861186ef9d98dbf..17540062e4baa3154a6872a0a4ccec3663993aae 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -279,21 +279,17 @@ impl ActiveCall { channel_id: u64, cx: &mut ModelContext, ) -> Task> { - let leave_room; if let Some(room) = self.room().cloned() { if room.read(cx).channel_id() == Some(channel_id) { return Task::ready(Ok(())); } else { - leave_room = room.update(cx, |room, cx| room.leave(cx)); + room.update(cx, |room, cx| room.clear_state(cx)); } - } else { - leave_room = Task::ready(Ok(())); } let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx); cx.spawn(|this, mut cx| async move { - leave_room.await?; let room = join.await?; this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx)) .await?; diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 5a4bc8329f6941404a2c723234c875701fba5a3f..a4ffa8866e63337d1059daff09a27338664f9d50 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -347,7 +347,18 @@ impl Room { } log::info!("leaving room"); + Audio::play_sound(Sound::Leave, cx); + + self.clear_state(cx); + + let leave_room = self.client.request(proto::LeaveRoom {}); + cx.background().spawn(async move { + leave_room.await?; + anyhow::Ok(()) + }) + } + pub(crate) fn clear_state(&mut self, cx: &mut AppContext) { for project in self.shared_projects.drain() { if let Some(project) = project.upgrade(cx) { project.update(cx, |project, cx| { @@ -364,8 +375,6 @@ impl Room { } } - Audio::play_sound(Sound::Leave, cx); - self.status = RoomStatus::Offline; self.remote_participants.clear(); self.pending_participants.clear(); @@ -374,12 +383,6 @@ impl Room { self.live_kit.take(); self.pending_room_update.take(); self.maintain_connection.take(); - - let leave_room = self.client.request(proto::LeaveRoom {}); - cx.background().spawn(async move { - leave_room.await?; - anyhow::Ok(()) - }) } async fn maintain_connection( diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 2396085a011ff4bf737003cb19f6d1535fa1dc4b..183aab84966ebddf20b5754ee19cc61daade7d04 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2415,6 +2415,7 @@ async fn join_channel( let channel_id = ChannelId::from_proto(request.channel_id); let joined_room = { + leave_room_for_session(&session).await?; let db = session.db().await; let room_id = db.room_id_for_channel(channel_id).await?; From 13cf3ada39473416cd4ef96930071aa732d0b651 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Tue, 15 Aug 2023 16:29:01 -0400 Subject: [PATCH 096/105] Update checked icon --- crates/collab_ui/src/collab_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 7ad7a8883d8c3c47dc43bb36447ea4e4bc80a37a..4c20411549443777dd504b101fefa18e00ee43b0 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1677,7 +1677,7 @@ impl CollabPanel { } else { theme.contact_button.style_for(mouse_state) }; - render_icon_button(button_style, "icons/check_8.svg") + render_icon_button(button_style, "icons/check.svg") .aligned() .flex_float() }) @@ -1762,7 +1762,7 @@ impl CollabPanel { } else { theme.contact_button.style_for(mouse_state) }; - render_icon_button(button_style, "icons/check_8.svg") + render_icon_button(button_style, "icons/check.svg") .aligned() .flex_float() }) From 943aeb8c09507deb7a44c4529a42bd5d73d7cb8d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 15 Aug 2023 13:42:47 -0700 Subject: [PATCH 097/105] Run until parked when setting editor's state via EditorTestContext Co-authored-by: Mikayla --- crates/vim/src/test/vim_test_context.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index ab5d7382c70590d983d2e251e0946e5ad5ed4e20..ff8d835edc6438d701eded44ba0c1082c6c513dd 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -92,6 +92,7 @@ impl<'a> VimTestContext<'a> { vim.switch_mode(mode, true, cx); }) }); + self.cx.foreground().run_until_parked(); context_handle } From 1ffde7bddc2d1e06cb849587532fa40f92b22c78 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 15 Aug 2023 14:56:54 -0700 Subject: [PATCH 098/105] Implement calling contacts into your current channel Co-authored-by: Mikayla --- crates/call/src/call.rs | 8 ++- crates/call/src/room.rs | 81 ++++++++++++------------ crates/collab/src/db.rs | 36 +++++++++-- crates/collab/src/db/tests.rs | 25 ++------ crates/collab/src/rpc.rs | 40 ++++++++---- crates/collab/src/tests/channel_tests.rs | 74 ++++++++++++++++++++++ crates/collab_ui/src/collab_panel.rs | 7 +- crates/rpc/proto/zed.proto | 3 +- 8 files changed, 187 insertions(+), 87 deletions(-) diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 17540062e4baa3154a6872a0a4ccec3663993aae..33ba7a2ab9f483694397f883c51ad3957d31aaaf 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; use call_settings::CallSettings; -use client::{proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore}; +use client::{ + proto, ChannelId, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore, +}; use collections::HashSet; use futures::{future::Shared, FutureExt}; use postage::watch; @@ -75,6 +77,10 @@ impl ActiveCall { } } + pub fn channel_id(&self, cx: &AppContext) -> Option { + self.room()?.read(cx).channel_id() + } + async fn handle_incoming_call( this: ModelHandle, envelope: TypedEnvelope, diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index a4ffa8866e63337d1059daff09a27338664f9d50..6f01b1d75789ce61d537be2d780f0dbb5960ad17 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -274,26 +274,13 @@ impl Room { user_store: ModelHandle, cx: &mut AppContext, ) -> Task>> { - cx.spawn(|mut cx| async move { - let response = client.request(proto::JoinChannel { channel_id }).await?; - let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; - let room = cx.add_model(|cx| { - Self::new( - room_proto.id, - Some(channel_id), - response.live_kit_connection_info, - client, - user_store, - cx, - ) - }); - - room.update(&mut cx, |room, cx| { - room.apply_room_update(room_proto, cx)?; - anyhow::Ok(()) - })?; - - Ok(room) + cx.spawn(|cx| async move { + Self::from_join_response( + client.request(proto::JoinChannel { channel_id }).await?, + client, + user_store, + cx, + ) }) } @@ -303,30 +290,42 @@ impl Room { user_store: ModelHandle, cx: &mut AppContext, ) -> Task>> { - let room_id = call.room_id; - cx.spawn(|mut cx| async move { - let response = client.request(proto::JoinRoom { id: room_id }).await?; - let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; - let room = cx.add_model(|cx| { - Self::new( - room_id, - None, - response.live_kit_connection_info, - client, - user_store, - cx, - ) - }); - room.update(&mut cx, |room, cx| { - room.leave_when_empty = true; - room.apply_room_update(room_proto, cx)?; - anyhow::Ok(()) - })?; - - Ok(room) + let id = call.room_id; + cx.spawn(|cx| async move { + Self::from_join_response( + client.request(proto::JoinRoom { id }).await?, + client, + user_store, + cx, + ) }) } + fn from_join_response( + response: proto::JoinRoomResponse, + client: Arc, + user_store: ModelHandle, + mut cx: AsyncAppContext, + ) -> Result> { + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.add_model(|cx| { + Self::new( + room_proto.id, + response.channel_id, + response.live_kit_connection_info, + client, + user_store, + cx, + ) + }); + room.update(&mut cx, |room, cx| { + room.leave_when_empty = room.channel_id.is_none(); + room.apply_room_update(room_proto, cx)?; + anyhow::Ok(()) + })?; + Ok(room) + } + fn should_leave(&self) -> bool { self.leave_when_empty && self.pending_room_update.is_none() diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index b7718be1187e7e07308a520bdea381776a2d7fd7..64349123af37c6ba336392a436631c94acc06e0e 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1376,15 +1376,27 @@ impl Database { &self, room_id: RoomId, user_id: UserId, - channel_id: Option, connection: ConnectionId, ) -> Result> { self.room_transaction(room_id, |tx| async move { + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryChannelId { + ChannelId, + } + let channel_id: Option = room::Entity::find() + .select_only() + .column(room::Column::ChannelId) + .filter(room::Column::Id.eq(room_id)) + .into_values::<_, QueryChannelId>() + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such room"))?; + if let Some(channel_id) = channel_id { self.check_user_is_channel_member(channel_id, user_id, &*tx) .await?; - room_participant::ActiveModel { + room_participant::Entity::insert_many([room_participant::ActiveModel { room_id: ActiveValue::set(room_id), user_id: ActiveValue::set(user_id), answering_connection_id: ActiveValue::set(Some(connection.id as i32)), @@ -1392,15 +1404,23 @@ impl Database { connection.owner_id as i32, ))), answering_connection_lost: ActiveValue::set(false), - // Redundant for the channel join use case, used for channel and call invitations calling_user_id: ActiveValue::set(user_id), calling_connection_id: ActiveValue::set(connection.id as i32), calling_connection_server_id: ActiveValue::set(Some(ServerId( connection.owner_id as i32, ))), ..Default::default() - } - .insert(&*tx) + }]) + .on_conflict( + OnConflict::columns([room_participant::Column::UserId]) + .update_columns([ + room_participant::Column::AnsweringConnectionId, + room_participant::Column::AnsweringConnectionServerId, + room_participant::Column::AnsweringConnectionLost, + ]) + .to_owned(), + ) + .exec(&*tx) .await?; } else { let result = room_participant::Entity::update_many() @@ -4053,6 +4073,12 @@ impl DerefMut for RoomGuard { } } +impl RoomGuard { + pub fn into_inner(self) -> T { + self.data + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct NewUserParams { pub github_login: String, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 2680d81aac8c9f3e4dce686b7274680556f4e388..dbbf162d1270133a85d942462d02085b8370809d 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -494,14 +494,9 @@ test_both_dbs!( ) .await .unwrap(); - db.join_room( - room_id, - user2.user_id, - None, - ConnectionId { owner_id, id: 1 }, - ) - .await - .unwrap(); + db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 }) + .await + .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0); db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[]) @@ -1113,12 +1108,7 @@ test_both_dbs!( // can join a room with membership to its channel let joined_room = db - .join_room( - room_1, - user_1, - Some(channel_1), - ConnectionId { owner_id, id: 1 }, - ) + .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 }) .await .unwrap(); assert_eq!(joined_room.room.participants.len(), 1); @@ -1126,12 +1116,7 @@ test_both_dbs!( drop(joined_room); // cannot join a room without membership to its channel assert!(db - .join_room( - room_1, - user_2, - Some(channel_1), - ConnectionId { owner_id, id: 1 } - ) + .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 }) .await .is_err()); } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 183aab84966ebddf20b5754ee19cc61daade7d04..521aa3e7b45b7be2683a4312395b8328df2892b0 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -930,16 +930,26 @@ async fn join_room( session: Session, ) -> Result<()> { let room_id = RoomId::from_proto(request.id); - let room = { + let joined_room = { let room = session .db() .await - .join_room(room_id, session.user_id, None, session.connection_id) + .join_room(room_id, session.user_id, session.connection_id) .await?; room_updated(&room.room, &session.peer); - room.room.clone() + room.into_inner() }; + if let Some(channel_id) = joined_room.channel_id { + channel_updated( + channel_id, + &joined_room.room, + &joined_room.channel_members, + &session.peer, + &*session.connection_pool().await, + ) + } + for connection_id in session .connection_pool() .await @@ -958,7 +968,10 @@ async fn join_room( let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() { if let Some(token) = live_kit - .room_token(&room.live_kit_room, &session.user_id.to_string()) + .room_token( + &joined_room.room.live_kit_room, + &session.user_id.to_string(), + ) .trace_err() { Some(proto::LiveKitConnectionInfo { @@ -973,7 +986,8 @@ async fn join_room( }; response.send(proto::JoinRoomResponse { - room: Some(room), + room: Some(joined_room.room), + channel_id: joined_room.channel_id.map(|id| id.to_proto()), live_kit_connection_info, })?; @@ -1151,9 +1165,11 @@ async fn rejoin_room( } } - room = mem::take(&mut rejoined_room.room); + let rejoined_room = rejoined_room.into_inner(); + + room = rejoined_room.room; channel_id = rejoined_room.channel_id; - channel_members = mem::take(&mut rejoined_room.channel_members); + channel_members = rejoined_room.channel_members; } if let Some(channel_id) = channel_id { @@ -2421,12 +2437,7 @@ async fn join_channel( let room_id = db.room_id_for_channel(channel_id).await?; let joined_room = db - .join_room( - room_id, - session.user_id, - Some(channel_id), - session.connection_id, - ) + .join_room(room_id, session.user_id, session.connection_id) .await?; let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| { @@ -2445,12 +2456,13 @@ async fn join_channel( response.send(proto::JoinRoomResponse { room: Some(joined_room.room.clone()), + channel_id: joined_room.channel_id.map(|id| id.to_proto()), live_kit_connection_info, })?; room_updated(&joined_room.room, &session.peer); - joined_room.clone() + joined_room.into_inner() }; channel_updated( diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index d4cf6423f0287f6fd184d6db7a654eb810580267..d778b6a472dafaf51acdabdd8b83700c00cfcda8 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -696,6 +696,80 @@ async fn test_channel_rename( ); } +#[gpui::test] +async fn test_call_from_channel( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let channel_id = server + .make_channel( + "x", + (&client_a, cx_a), + &mut [(&client_b, cx_b), (&client_c, cx_c)], + ) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + active_call_a + .update(cx_a, |call, cx| call.join_channel(channel_id, cx)) + .await + .unwrap(); + + // Client A calls client B while in the channel. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + + // Client B accepts the call. + deterministic.run_until_parked(); + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + + // Client B sees that they are now in the channel + deterministic.run_until_parked(); + active_call_b.read_with(cx_b, |call, cx| { + assert_eq!(call.channel_id(cx), Some(channel_id)); + }); + client_b.channel_store().read_with(cx_b, |channels, _| { + assert_participants_eq( + channels.channel_participants(channel_id), + &[client_a.user_id().unwrap(), client_b.user_id().unwrap()], + ); + }); + + // Clients A and C also see that client B is in the channel. + client_a.channel_store().read_with(cx_a, |channels, _| { + assert_participants_eq( + channels.channel_participants(channel_id), + &[client_a.user_id().unwrap(), client_b.user_id().unwrap()], + ); + }); + client_c.channel_store().read_with(cx_c, |channels, _| { + assert_participants_eq( + channels.channel_participants(channel_id), + &[client_a.user_id().unwrap(), client_b.user_id().unwrap()], + ); + }); +} + #[derive(Debug, PartialEq)] struct ExpectedChannel { depth: usize, diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 4c20411549443777dd504b101fefa18e00ee43b0..498b278abd13b17b478a75476b0a38b1a3d95910 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1183,11 +1183,8 @@ impl CollabPanel { let text = match section { Section::ActiveCall => { let channel_name = iife!({ - let channel_id = ActiveCall::global(cx) - .read(cx) - .room()? - .read(cx) - .channel_id()?; + let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?; + let name = self .channel_store .read(cx) diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index fc9a66753cf21d3930d1a2804ed19bac22128348..caa5efd2cb10271ab3320f76cbc0e38fce257764 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -176,7 +176,8 @@ message JoinRoom { message JoinRoomResponse { Room room = 1; - optional LiveKitConnectionInfo live_kit_connection_info = 2; + optional uint64 channel_id = 2; + optional LiveKitConnectionInfo live_kit_connection_info = 3; } message RejoinRoom { From 28649fb71d4a1bbf598d81806735c73a1fdfcf96 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Tue, 15 Aug 2023 18:36:23 -0400 Subject: [PATCH 099/105] Update channel context menu --- crates/collab_ui/src/collab_panel.rs | 11 +++++++---- styles/src/style_tree/context_menu.ts | 12 +----------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 498b278abd13b17b478a75476b0a38b1a3d95910..ce4ffc8f6b14934d9ba7f48a8f9c3fbf4dbde046 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1834,10 +1834,13 @@ impl CollabPanel { }, vec![ ContextMenuItem::action("New Subchannel", NewChannel { channel_id }), - ContextMenuItem::action("Remove Channel", RemoveChannel { channel_id }), - ContextMenuItem::action("Manage members", ManageMembers { channel_id }), - ContextMenuItem::action("Invite members", InviteMembers { channel_id }), - ContextMenuItem::action("Rename Channel", RenameChannel { channel_id }), + ContextMenuItem::Separator, + ContextMenuItem::action("Invite to Channel", InviteMembers { channel_id }), + ContextMenuItem::Separator, + ContextMenuItem::action("Rename", RenameChannel { channel_id }), + ContextMenuItem::action("Manage", ManageMembers { channel_id }), + ContextMenuItem::Separator, + ContextMenuItem::action("Delete", RemoveChannel { channel_id }), ], cx, ); diff --git a/styles/src/style_tree/context_menu.ts b/styles/src/style_tree/context_menu.ts index d4266a71fe6f3974dcf8a154002f658fadf38d07..84688c09716cf524b3de74733107a9dd281bcd01 100644 --- a/styles/src/style_tree/context_menu.ts +++ b/styles/src/style_tree/context_menu.ts @@ -19,7 +19,7 @@ export default function context_menu(): any { icon_width: 14, padding: { left: 6, right: 6, top: 2, bottom: 2 }, corner_radius: 6, - label: text(theme.middle, "sans", { size: "sm" }), + label: text(theme.middle, "ui_sans", { size: "sm" }), keystroke: { ...text(theme.middle, "sans", "variant", { size: "sm", @@ -31,16 +31,6 @@ export default function context_menu(): any { state: { hovered: { background: background(theme.middle, "hovered"), - label: text(theme.middle, "sans", "hovered", { - size: "sm", - }), - keystroke: { - ...text(theme.middle, "sans", "hovered", { - size: "sm", - weight: "bold", - }), - padding: { left: 3, right: 3 }, - }, }, clicked: { background: background(theme.middle, "pressed"), From a56747af8c6ea205ade7f2c99431d8e55ac89f6b Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Tue, 15 Aug 2023 18:36:30 -0400 Subject: [PATCH 100/105] Update assistant status bar icon --- assets/icons/ai.svg | 32 ++++++++++++++------------------ crates/ai/src/assistant.rs | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/assets/icons/ai.svg b/assets/icons/ai.svg index fa046c605087b6ded13f347d622b376afdb437aa..5b3faaa9ccd8683d868173d783d45a9ef111d7cd 100644 --- a/assets/icons/ai.svg +++ b/assets/icons/ai.svg @@ -1,27 +1,23 @@ - - + + - + - - - - - - - - - - - - - - + + + + + + + + + + - + diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 9dc17e2ec5c27d0bbe853a7e467cb4a90f117094..e0fe41aebee1cbc305422c036ef898f732a2c238 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -784,7 +784,7 @@ impl Panel for AssistantPanel { fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> { settings::get::(cx) .button - .then(|| "icons/robot_14.svg") + .then(|| "icons/ai.svg") } fn icon_tooltip(&self) -> (String, Option>) { From 706227701ec284ff926115d284381ba4cc1be7fc Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 15 Aug 2023 16:14:24 -0700 Subject: [PATCH 101/105] Keep collab panel focused after deleting a channel --- crates/collab_ui/src/collab_panel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index ce4ffc8f6b14934d9ba7f48a8f9c3fbf4dbde046..f113f12f9db754195ae640f8af8342d7f10eded6 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2167,7 +2167,7 @@ impl CollabPanel { let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]); let window = cx.window(); - cx.spawn(|_, mut cx| async move { + cx.spawn(|this, mut cx| async move { if answer.next().await == Some(0) { if let Err(e) = channel_store .update(&mut cx, |channels, _| channels.remove_channel(channel_id)) @@ -2180,6 +2180,7 @@ impl CollabPanel { &mut cx, ); } + this.update(&mut cx, |_, cx| cx.focus_self()).ok(); } }) .detach(); From 0524abf11478b0a86610fd6c3b9a77eab1f99a50 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Tue, 15 Aug 2023 23:19:11 -0700 Subject: [PATCH 102/105] Lazily initialize and destroy the audio handle state on call initiation and end --- crates/audio/src/audio.rs | 40 ++++++++++++++++++++++++++------------- crates/call/src/call.rs | 2 ++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 233b0f62aa5aa02d82e1524a2c7ea0fa96b836ce..d80fb6738f69891a9199f230af832ee335071496 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -39,29 +39,43 @@ pub struct Audio { impl Audio { pub fn new() -> Self { - let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip(); - Self { - _output_stream, - output_handle, + _output_stream: None, + output_handle: None, } } - pub fn play_sound(sound: Sound, cx: &AppContext) { + fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> { + if self.output_handle.is_none() { + let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip(); + self.output_handle = output_handle; + self._output_stream = _output_stream; + } + + self.output_handle.as_ref() + } + + pub fn play_sound(sound: Sound, cx: &mut AppContext) { if !cx.has_global::() { return; } - let this = cx.global::(); + cx.update_global::(|this, cx| { + let output_handle = this.ensure_output_exists()?; + let source = SoundRegistry::global(cx).get(sound.file()).log_err()?; + output_handle.play_raw(source).log_err()?; + Some(()) + }); + } - let Some(output_handle) = this.output_handle.as_ref() else { + pub fn end_call(cx: &mut AppContext) { + if !cx.has_global::() { return; - }; - - let Some(source) = SoundRegistry::global(cx).get(sound.file()).log_err() else { - return; - }; + } - output_handle.play_raw(source).log_err(); + cx.update_global::(|this, _| { + this._output_stream.take(); + this.output_handle.take(); + }); } } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 33ba7a2ab9f483694397f883c51ad3957d31aaaf..3ac29bfc85af9c47d790168aa2c32f49c14977be 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -5,6 +5,7 @@ pub mod room; use std::sync::Arc; use anyhow::{anyhow, Result}; +use audio::Audio; use call_settings::CallSettings; use client::{ proto, ChannelId, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore, @@ -309,6 +310,7 @@ impl ActiveCall { pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { cx.notify(); self.report_call_event("hang up", cx); + Audio::end_call(cx); if let Some((room, _)) = self.room.take() { room.update(cx, |room, cx| room.leave(cx)) } else { From 6c15636ccccdad1cd3db0da1efeacd4ef6538011 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 12:38:44 -0400 Subject: [PATCH 103/105] Style cleanup for channels panel --- styles/src/component/button.ts | 122 ++++++++++++++++++++++++-- styles/src/component/icon_button.ts | 36 +++++--- styles/src/component/label_button.ts | 78 ++++++++++++++++ styles/src/component/text_button.ts | 8 +- styles/src/element/index.ts | 4 +- styles/src/element/toggle.ts | 2 +- styles/src/style_tree/collab_panel.ts | 36 ++++---- 7 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 styles/src/component/label_button.ts diff --git a/styles/src/component/button.ts b/styles/src/component/button.ts index ba72851768edf7d46c6c8cb774b777b92ecc6da1..3b554ae37aa7db3e7e634d447cd14afe2d6f3a5c 100644 --- a/styles/src/component/button.ts +++ b/styles/src/component/button.ts @@ -1,6 +1,118 @@ -export const ButtonVariant = { - Default: 'default', - Ghost: 'ghost' -} as const +import { font_sizes, useTheme } from "../common" +import { Layer, Theme } from "../theme" +import { TextStyle, background } from "../style_tree/components" -export type Variant = typeof ButtonVariant[keyof typeof ButtonVariant] +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Button { + export type Options = { + layer: Layer, + background: keyof Theme["lowest"] + color: keyof Theme["lowest"] + variant: Button.Variant + size: Button.Size + shape: Button.Shape + margin: { + top?: number + bottom?: number + left?: number + right?: number + }, + states: { + enabled?: boolean, + hovered?: boolean, + pressed?: boolean, + focused?: boolean, + disabled?: boolean, + } + } + + export type ToggleableOptions = Options & { + active_background: keyof Theme["lowest"] + active_color: keyof Theme["lowest"] + } + + /** Padding added to each side of a Shape.Rectangle button */ + export const RECTANGLE_PADDING = 2 + export const FONT_SIZE = font_sizes.sm + export const ICON_SIZE = 14 + export const CORNER_RADIUS = 6 + + export const variant = { + Default: 'filled', + Outline: 'outline', + Ghost: 'ghost' + } as const + + export type Variant = typeof variant[keyof typeof variant] + + export const shape = { + Rectangle: 'rectangle', + Square: 'square' + } as const + + export type Shape = typeof shape[keyof typeof shape] + + export const size = { + Small: "sm", + Medium: "md" + } as const + + export type Size = typeof size[keyof typeof size] + + export type BaseStyle = { + corder_radius: number + background: string | null + padding: { + top: number + bottom: number + left: number + right: number + }, + margin: Button.Options['margin'] + button_height: number + } + + export type LabelButtonStyle = BaseStyle & TextStyle + // export type IconButtonStyle = ButtonStyle + + export const button_base = ( + options: Partial = { + variant: Button.variant.Default, + shape: Button.shape.Rectangle, + states: { + hovered: true, + pressed: true + } + } + ): BaseStyle => { + const theme = useTheme() + + const layer = options.layer ?? theme.middle + const color = options.color ?? "base" + const background_color = options.variant === Button.variant.Ghost ? null : background(layer, options.background ?? color) + + const m = { + top: options.margin?.top ?? 0, + bottom: options.margin?.bottom ?? 0, + left: options.margin?.left ?? 0, + right: options.margin?.right ?? 0, + } + const size = options.size || Button.size.Medium + const padding = 2 + + const base: BaseStyle = { + background: background_color, + corder_radius: Button.CORNER_RADIUS, + padding: { + top: padding, + bottom: padding, + left: options.shape === Button.shape.Rectangle ? padding + Button.RECTANGLE_PADDING : padding, + right: options.shape === Button.shape.Rectangle ? padding + Button.RECTANGLE_PADDING : padding + }, + margin: m, + button_height: 16, + } + + return base + } +} diff --git a/styles/src/component/icon_button.ts b/styles/src/component/icon_button.ts index ae3fa763e72a47943102e549538381db3516e7c4..1a2d0bcec491abdad0bc43a7c5d1599052aca622 100644 --- a/styles/src/component/icon_button.ts +++ b/styles/src/component/icon_button.ts @@ -1,7 +1,7 @@ import { interactive, toggleable } from "../element" import { background, foreground } from "../style_tree/components" -import { useTheme, Theme } from "../theme" -import { ButtonVariant, Variant } from "./button" +import { useTheme, Theme, Layer } from "../theme" +import { Button } from "./button" export type Margin = { top: number @@ -17,19 +17,24 @@ interface IconButtonOptions { | Theme["highest"] color?: keyof Theme["lowest"] margin?: Partial - variant?: Variant + variant?: Button.Variant + size?: Button.Size } type ToggleableIconButtonOptions = IconButtonOptions & { active_color?: keyof Theme["lowest"] + active_layer?: Layer } -export function icon_button({ color, margin, layer, variant = ButtonVariant.Default }: IconButtonOptions) { +export function icon_button({ color, margin, layer, variant, size }: IconButtonOptions = { + variant: Button.variant.Default, + size: Button.size.Medium, +}) { const theme = useTheme() if (!color) color = "base" - const background_color = variant === ButtonVariant.Ghost ? null : background(layer ?? theme.lowest, color) + const background_color = variant === Button.variant.Ghost ? null : background(layer ?? theme.lowest, color) const m = { top: margin?.top ?? 0, @@ -38,15 +43,17 @@ export function icon_button({ color, margin, layer, variant = ButtonVariant.Defa right: margin?.right ?? 0, } + const padding = { + top: size === Button.size.Small ? 0 : 2, + bottom: size === Button.size.Small ? 0 : 2, + left: size === Button.size.Small ? 0 : 4, + right: size === Button.size.Small ? 0 : 4, + } + return interactive({ base: { corner_radius: 6, - padding: { - top: 2, - bottom: 2, - left: 4, - right: 4, - }, + padding: padding, margin: m, icon_width: 14, icon_height: 14, @@ -72,17 +79,18 @@ export function icon_button({ color, margin, layer, variant = ButtonVariant.Defa export function toggleable_icon_button( theme: Theme, - { color, active_color, margin, variant }: ToggleableIconButtonOptions + { color, active_color, margin, variant, size, active_layer }: ToggleableIconButtonOptions ) { if (!color) color = "base" return toggleable({ state: { - inactive: icon_button({ color, margin, variant }), + inactive: icon_button({ color, margin, variant, size }), active: icon_button({ color: active_color ? active_color : color, margin, - layer: theme.middle, + layer: active_layer, + size }), }, }) diff --git a/styles/src/component/label_button.ts b/styles/src/component/label_button.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f1c54a7f684198d00f30f294307b3d9d6b9d472 --- /dev/null +++ b/styles/src/component/label_button.ts @@ -0,0 +1,78 @@ +import { Interactive, interactive, toggleable, Toggleable } from "../element" +import { TextStyle, background, text } from "../style_tree/components" +import { useTheme } from "../theme" +import { Button } from "./button" + +type LabelButtonStyle = { + corder_radius: number + background: string | null + padding: { + top: number + bottom: number + left: number + right: number + }, + margin: Button.Options['margin'] + button_height: number +} & TextStyle + +/** Styles an Interactive<ContainedText> */ +export function label_button_style( + options: Partial = { + variant: Button.variant.Default, + shape: Button.shape.Rectangle, + states: { + hovered: true, + pressed: true + } + } +): Interactive { + const theme = useTheme() + + const base = Button.button_base(options) + const layer = options.layer ?? theme.middle + const color = options.color ?? "base" + + const default_state = { + ...base, + ...text(layer ?? theme.lowest, "sans", color), + font_size: Button.FONT_SIZE, + } + + return interactive({ + base: default_state, + state: { + hovered: { + background: background(layer, options.background ?? color, "hovered") + }, + clicked: { + background: background(layer, options.background ?? color, "pressed") + } + } + }) +} + +/** Styles an Toggleable<Interactive<ContainedText>> */ +export function toggle_label_button_style( + options: Partial = { + variant: Button.variant.Default, + shape: Button.shape.Rectangle, + states: { + hovered: true, + pressed: true + } + } +): Toggleable> { + const activeOptions = { + ...options, + color: options.active_color || options.color, + background: options.active_background || options.background + } + + return toggleable({ + state: { + inactive: label_button_style(options), + active: label_button_style(activeOptions), + }, + }) +} diff --git a/styles/src/component/text_button.ts b/styles/src/component/text_button.ts index 2be2dd19cbae54b8025413189fa0cd5168d08977..b911cd5b778c54b2f4b58cd8aa7911b0fd9d553d 100644 --- a/styles/src/component/text_button.ts +++ b/styles/src/component/text_button.ts @@ -6,7 +6,7 @@ import { text, } from "../style_tree/components" import { useTheme, Theme } from "../theme" -import { ButtonVariant, Variant } from "./button" +import { Button } from "./button" import { Margin } from "./icon_button" interface TextButtonOptions { @@ -14,7 +14,7 @@ interface TextButtonOptions { | Theme["lowest"] | Theme["middle"] | Theme["highest"] - variant?: Variant + variant?: Button.Variant color?: keyof Theme["lowest"] margin?: Partial text_properties?: TextProperties @@ -25,7 +25,7 @@ type ToggleableTextButtonOptions = TextButtonOptions & { } export function text_button({ - variant = ButtonVariant.Default, + variant = Button.variant.Default, color, layer, margin, @@ -34,7 +34,7 @@ export function text_button({ const theme = useTheme() if (!color) color = "base" - const background_color = variant === ButtonVariant.Ghost ? null : background(layer ?? theme.lowest, color) + const background_color = variant === Button.variant.Ghost ? null : background(layer ?? theme.lowest, color) const text_options: TextProperties = { size: "xs", diff --git a/styles/src/element/index.ts b/styles/src/element/index.ts index 81c911c7bd6852a0919afadcd2ca27114de152f6..d41b4e2cc3bd429993b490203a7278a74eaad258 100644 --- a/styles/src/element/index.ts +++ b/styles/src/element/index.ts @@ -1,4 +1,4 @@ import { interactive, Interactive } from "./interactive" -import { toggleable } from "./toggle" +import { toggleable, Toggleable } from "./toggle" -export { interactive, Interactive, toggleable } +export { interactive, Interactive, toggleable, Toggleable } diff --git a/styles/src/element/toggle.ts b/styles/src/element/toggle.ts index c3cde46d65962e90e966435c9bc07d5d4d023d93..25217444dab599bba46e4c7a46a2f03b4bf83a99 100644 --- a/styles/src/element/toggle.ts +++ b/styles/src/element/toggle.ts @@ -3,7 +3,7 @@ import { DeepPartial } from "utility-types" type ToggleState = "inactive" | "active" -type Toggleable = Record +export type Toggleable = Record export const NO_INACTIVE_OR_BASE_ERROR = "A toggleable object must have an inactive state, or a base property." diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index c65887e17cdf8f0022f5054919eecccfcb115b35..61c96ad75a1d374eb01410adce0e29e738fe95a0 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -9,7 +9,7 @@ import { interactive, toggleable } from "../element" import { useTheme } from "../theme" import collab_modals from "./collab_modals" import { text_button } from "../component/text_button" -import { toggleable_icon_button } from "../component/icon_button" +import { icon_button, toggleable_icon_button } from "../component/icon_button" import { indicator } from "../component/indicator" export default function contacts_panel(): any { @@ -27,7 +27,7 @@ export default function contacts_panel(): any { color: foreground(layer, "on"), icon_width: 14, button_width: 16, - corner_radius: 8, + corner_radius: 8 } const project_row = { @@ -62,8 +62,9 @@ export default function contacts_panel(): any { } const header_icon_button = toggleable_icon_button(theme, { - layer: theme.middle, variant: "ghost", + size: "sm", + active_layer: theme.lowest, }) const subheader_row = toggleable({ @@ -87,8 +88,8 @@ export default function contacts_panel(): any { state: { active: { default: { - ...text(layer, "ui_sans", "active", { size: "sm" }), - background: background(layer, "active"), + ...text(theme.lowest, "ui_sans", { size: "sm" }), + background: background(theme.lowest), }, clicked: { background: background(layer, "pressed"), @@ -140,8 +141,8 @@ export default function contacts_panel(): any { }, active: { default: { - ...text(layer, "ui_sans", "active", { size: "sm" }), - background: background(layer, "active"), + ...text(theme.lowest, "ui_sans", { size: "sm" }), + background: background(theme.lowest), }, clicked: { background: background(layer, "pressed"), @@ -221,8 +222,8 @@ export default function contacts_panel(): any { }, active: { default: { - ...text(layer, "ui_sans", "active", { size: "sm" }), - background: background(layer, "active"), + ...text(theme.lowest, "ui_sans", { size: "sm" }), + background: background(theme.lowest), }, clicked: { background: background(layer, "pressed"), @@ -271,8 +272,8 @@ export default function contacts_panel(): any { }, active: { default: { - ...text(layer, "ui_sans", "active", { size: "sm" }), - background: background(layer, "active"), + ...text(theme.lowest, "ui_sans", { size: "sm" }), + background: background(theme.lowest), }, clicked: { background: background(layer, "pressed"), @@ -306,13 +307,10 @@ export default function contacts_panel(): any { }, }, contact_button_spacing: NAME_MARGIN, - contact_button: interactive({ - base: { ...contact_button }, - state: { - hovered: { - background: background(layer, "hovered"), - }, - }, + contact_button: icon_button({ + variant: "ghost", + color: "variant", + size: "sm", }), disabled_button: { ...contact_button, @@ -364,7 +362,7 @@ export default function contacts_panel(): any { }), state: { active: { - default: { background: background(layer, "active") }, + default: { background: background(theme.lowest) }, }, }, }), From 43127384c6ffddf0338dbfc7d93e825ac6132ad3 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 13:48:12 -0400 Subject: [PATCH 104/105] Update modal icon styles Co-Authored-By: Max Brunsfeld --- assets/icons/ellipsis.svg | 5 ++++ .../src/collab_panel/channel_modal.rs | 21 +++++++++---- crates/theme/src/theme.rs | 4 +-- styles/src/style_tree/collab_modals.ts | 30 +++++-------------- 4 files changed, 30 insertions(+), 30 deletions(-) create mode 100644 assets/icons/ellipsis.svg diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg new file mode 100644 index 0000000000000000000000000000000000000000..1858c655202cf6940c90278b43241bb1cabc32ac --- /dev/null +++ b/assets/icons/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs index 3e4f274f234494e47660d9b7b51aadb6b7f6a4ca..75ab40be85adb1e1df7678cf292c4c177237db0c 100644 --- a/crates/collab_ui/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -423,30 +423,39 @@ impl PickerDelegate for ChannelModalDelegate { .with_children({ let svg = match self.mode { Mode::ManageMembers => Some( - Svg::new("icons/ellipsis_14.svg") + Svg::new("icons/ellipsis.svg") .with_color(theme.member_icon.color) .constrained() - .with_width(theme.member_icon.width) + .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_8.svg") + Svg::new("icons/check.svg") .with_color(theme.member_icon.color) .constrained() - .with_width(theme.member_icon.width) + .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_8.svg") + Svg::new("icons/check.svg") .with_color(theme.invitee_icon.color) .constrained() - .with_width(theme.invitee_icon.width) + .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), ), diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 912ca0e8b87bc5b55307d3a9f549341bc4bdf80a..69fa7a09b3efa908b2da9a8a89e9fa533aba9505 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -276,8 +276,8 @@ pub struct ChannelModal { pub contact_username: ContainerStyle, pub remove_member_button: ContainedText, pub cancel_invite_button: ContainedText, - pub member_icon: Icon, - pub invitee_icon: Icon, + pub member_icon: IconButton, + pub invitee_icon: IconButton, pub member_tag: ContainedText, } diff --git a/styles/src/style_tree/collab_modals.ts b/styles/src/style_tree/collab_modals.ts index c0bf358e71624fc7d4aa6a6e26c57042d1e861ee..4bdeb45f9c9c025258676af44b661be06ca3521e 100644 --- a/styles/src/style_tree/collab_modals.ts +++ b/styles/src/style_tree/collab_modals.ts @@ -4,6 +4,7 @@ import picker from "./picker" import { input } from "../component/input" import contact_finder from "./contact_finder" import { tab } from "../component/tab" +import { icon_button } from "../component/icon_button" export default function channel_modal(): any { const theme = useTheme() @@ -26,6 +27,11 @@ export default function channel_modal(): any { const picker_input = input() + const member_icon_style = icon_button({ + variant: "ghost", + size: "sm", + }).default + return { contact_finder: contact_finder(), tabbed_modal: { @@ -93,29 +99,9 @@ export default function channel_modal(): any { }, channel_modal: { // This is used for the icons that are rendered to the right of channel Members in both UIs - member_icon: { - background: background(theme.middle), - padding: { - bottom: 4, - left: 4, - right: 4, - top: 4, - }, - width: 5, - color: foreground(theme.middle, "accent"), - }, + member_icon: member_icon_style, // This is used for the icons that are rendered to the right of channel invites in both UIs - invitee_icon: { - background: background(theme.middle), - padding: { - bottom: 4, - left: 4, - right: 4, - top: 4, - }, - width: 5, - color: foreground(theme.middle, "accent"), - }, + invitee_icon: member_icon_style, remove_member_button: { ...text(theme.middle, "sans", { size: "xs" }), background: background(theme.middle), From 925e09e0129617ff05c3bbda7c350ba90e9b6c7b Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 13:56:11 -0400 Subject: [PATCH 105/105] Update collab panel empty state to match project panel Co-Authored-By: Max Brunsfeld --- crates/collab_ui/src/collab_panel.rs | 2 ++ styles/src/style_tree/collab_panel.ts | 32 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index f113f12f9db754195ae640f8af8342d7f10eded6..4f0a61bf6a375d20808e3a87e73c41262f60580f 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2299,6 +2299,8 @@ impl View for CollabPanel { MouseEventHandler::new::(0, cx, |state, _| { let button = theme.log_in_button.style_for(state); Label::new("Sign in to collaborate", button.text.clone()) + .aligned() + .left() .contained() .with_style(button.container) }) diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 61c96ad75a1d374eb01410adce0e29e738fe95a0..2d8c0508381407104b42526bb4659ac678769b2c 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -153,7 +153,37 @@ export default function contacts_panel(): any { return { ...collab_modals(), - log_in_button: text_button(), + log_in_button: interactive({ + base: { + background: background(theme.middle), + border: border(theme.middle, "active"), + corner_radius: 4, + margin: { + top: 4, + left: 16, + right: 16, + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(theme.middle, "sans", "default", { size: "sm" }), + }, + state: { + hovered: { + ...text(theme.middle, "sans", "default", { size: "sm" }), + background: background(theme.middle, "hovered"), + border: border(theme.middle, "active"), + }, + clicked: { + ...text(theme.middle, "sans", "default", { size: "sm" }), + background: background(theme.middle, "pressed"), + border: border(theme.middle, "active"), + }, + }, + }), background: background(layer), padding: { top: SPACING,