From ac35dae66ec56b18e20de5665c8b48c747ec190d Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 18 Jul 2023 18:55:54 -0700 Subject: [PATCH 001/128] 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 535c20bcb9..e0a4b6a7bf 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 6e79c6b657..8803d1c34b 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 397dac0961..c40ed4e8da 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 0000000000..7507072130 --- /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 0000000000..8e55441b29 --- /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 0000000000..73697b3b72 --- /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 0000000000..fe3484b782 --- /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 857f3f56fc..40959c8f5c 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 e6e1cff598..67a23f8d77 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 4766f636f3..844b093a5e 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 308ea6f2d7..76f6883f0e 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 ebaf399e22..3b0dc81920 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 a5877aaccb..71d8461b01 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 e44ab3e33a..5739052b67 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 4b0bf1cd4c..c1046c0995 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 ee0aa133a0..d504f8e623 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 0000000000..b46db5dc38 --- /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/128] 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 8e55441b29..7560a36015 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 73697b3b72..063f652191 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 40959c8f5c..857f3f56fc 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 844b093a5e..56b3b2d156 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 b46db5dc38..126bbbe18c 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/128] 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 e0a4b6a7bf..617d2c9a81 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 8803d1c34b..6e79c6b657 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 adc55f8c91..5c14d818a7 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 7507072130..0000000000 --- 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 7560a36015..0000000000 --- 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 4a38c2691c..2ceac649ec 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 a54c0e9e79..0d273fd1b8 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 df4b502391..edbb89e339 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 063f652191..8fec29133f 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 1d6d1c84c7..a1c1061f5e 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 428f2156d1..f37d64cd05 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 78403444ff..746238aaa9 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 605b05a562..e24d6cb4b7 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 71d8461b01..a5877aaccb 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 5739052b67..e44ab3e33a 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 c1046c0995..b2d1c2a7a2 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/128] Reinstate all of the contacts popovers' functionality in the new collaboration panel --- crates/collab_ui/src/panel.rs | 1377 +++++++++++++++- .../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, 1342 insertions(+), 1577 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 8fec29133f..28cb57cf79 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(), - ) - .into_any(), - ), + ChildView::new(&self.filter_editor, cx) + .contained() + .with_style(theme.contact_list.user_query_editor.container) + .flex(1.0, true), ) - .with_child(ChildView::new(&self.contacts, cx)), + .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, |_, 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), + ) + .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 a1c1061f5e..0000000000 --- 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 f37d64cd05..0000000000 --- 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 746238aaa9..533a5de159 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 0ce63d088a..7ca258d166 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/128] 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 28cb57cf79..4fee5f66f1 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 3264a144ed..a5868f8d2f 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 56b3b2d156..6673efac2d 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 d504f8e623..6d7ed27884 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 1955231f59..c457468e20 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/128] 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 054b283791..79fc23585f 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/128] 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 | 445 ++++++++++--------- 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, 325 insertions(+), 258 deletions(-) diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 0d273fd1b8..8a6bf5bc83 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 4fee5f66f1..e78f3ce22f 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(), - }), - ); - 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())), - ); - } + self.entries + .push(ContactEntry::Header(Section::Channels, 0)); - 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())), - ); - } + self.entries + .push(ContactEntry::Header(Section::Contacts, 0)); - 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 !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 contacts = user_store.contacts(); - if !contacts.is_empty() { - self.match_candidates.clear(); - self.match_candidates - .extend( - contacts - .iter() - .enumerate() - .map(|(ix, contact)| StringMatchCandidate { + 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); + } + } + + 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 a5868f8d2f..41fff2af43 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 6673efac2d..c06a71d2db 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 6d7ed27884..d017ce90ca 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 c457468e20..39ee9b610f 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 7ca258d166..0e76bbb38a 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 fe0c53e87d..a93bf376c0 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/128] 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 e78f3ce22f..bf0397ec76 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 c06a71d2db..e13c8daafc 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 39ee9b610f..4f847081ab 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/128] 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 bf0397ec76..bc79694d53 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 e13c8daafc..3de878118e 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 d017ce90ca..fab751d0d1 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 126bbbe18c..0000000000 --- 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 4f847081ab..8e817add3f 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/128] 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 0000000000..562536d58c --- /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/128] 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 c690b6148a..a446f6b440 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 0000000000..a62eb0aaaf --- /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 e16fa9edb1..ca7227917c 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 0000000000..ebf5c26ac8 --- /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 0000000000..cad7f3853d --- /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 0000000000..bf6cb44711 --- /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 c3e88670eb..c838d1273b 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 c2b157bd0a..2d0e2fdf0b 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 bc79694d53..bdeac59af9 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/128] WIP: Channel CRUD --- .../20221109000000_test_schema.sql | 6 +- crates/collab/src/db.rs | 283 ++++++++++-------- crates/collab/src/db/channel.rs | 1 + crates/collab/src/tests.rs | 1 + 4 files changed, 163 insertions(+), 128 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index a446f6b440..ed7459e4a0 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 ca7227917c..c8bec8a3f9 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> { + pub async fn create_channel(&self, name: &str) -> Result { + self.transaction(move |tx| async move { + let tx = tx; + + let channel = channel::ActiveModel { + name: ActiveValue::Set(name.to_string()), + ..Default::default() + }; + + 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 channel_membership = channel_member::ActiveModel { + channel_id: ActiveValue::Set(channel_id), + user_id: ActiveValue::Set(user_id), + ..Default::default() + }; + + channel_membership.insert(&*tx).await?; + + Ok(()) + }).await + } + + pub async fn get_channels(&self, user_id: UserId) -> Vec { 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 + // 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 ebf5c26ac8..f8e2c3b85b 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 b1d0bedb2c..2e98cd9b4d 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/128] 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 0000000000..24754adeb3 --- /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/128] 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 ed7459e4a0..b397438e27 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 c8bec8a3f9..5755ed73e2 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 f8e2c3b85b..48e5d50e3e 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 bf6cb44711..b0072155a3 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 24754adeb3..8ab33adcbf 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; + 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), + } + ] + ); +} - assert_eq!(channels, vec![zed_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(); + + 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/128] 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 a62eb0aaaf..0073d29c68 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 5755ed73e2..d3336824e6 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 855dfec91f..53c35ef31b 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/128] 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 0000000000..a72a189415 --- /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 78bcc55e93..af33c738ce 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 b397438e27..1ead36fde2 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 0073d29c68..0588677792 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 d3336824e6..46fca04658 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 cad7f3853d..f0f1a852cb 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 53c35ef31b..03e9eb577b 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 14d785307d..3d95d484ee 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 2e98cd9b4d..cf302d3b4d 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 8ab33adcbf..4cc0d24d9b 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(), + 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: 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_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 a0b98372b1..38ffbe6b7e 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 e24d6cb4b7..1e9e93a2d0 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/128] 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 2defd6b40f..1e3a381b40 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 328a94506c..e77b5437b5 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 a72a189415..e78dafe4e8 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 1ead36fde2..6703f98df2 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 0588677792..2d94cb6d97 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 46fca04658..5f106023f1 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 48e5d50e3e..8834190645 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 c838d1273b..88514ef4f1 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 03e9eb577b..7ef2b39640 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 3d95d484ee..8cf0b7e48c 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, live_kit_connection_info) = create_room(&session).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()), - 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 cf302d3b4d..a000fbd92e 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 4cc0d24d9b..c86238825c 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 ab94f16a07..5a27787dbc 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 38ffbe6b7e..8a4a72c268 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 1e9e93a2d0..c3d65343d6 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 6b430d90e4..3cb8b6bffa 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/128] 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 1e3a381b40..3cd868a438 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 e77b5437b5..683ff6f4df 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 5f106023f1..f87b68c1ec 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 7ef2b39640..719e8693d4 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 8cf0b7e48c..0abf2c44a7 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, live_kit_connection_info) = create_room(&session).await?; + let room = db + .join_room( + room_id, + session.user_id, + Some(channel_id), + session.connection_id, + ) + .await?; - // db.set_channel_room(channel_id, room.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()?; - response.send(proto::CreateRoomResponse { - room: Some(room.clone()), - live_kit_connection_info, - })?; + Some(LiveKitConnectionInfo { + server_url: live_kit.url().into(), + token, + }) + }); + + response.send(proto::JoinRoomResponse { + 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 c86238825c..632bfdca49 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 c3d65343d6..d71ddeed83 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/128] Start work on displaying channels and invites in collab panel --- crates/client/src/channel_store.rs | 127 +++++++------ 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, 412 insertions(+), 70 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 e78dafe4e8..678e712c7d 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)); + this.update_channels(message.payload, cx); + }); + Ok(()) + } - 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; + 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; + } + + 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) = 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: Some(parent_id), + depth, + }), + ); } - - this.channel_invitations.insert( + } else { + self.channels.insert( 0, - Channel { + Arc::new(Channel { id: channel.id, name: channel.name, parent_id: None, - }, + depth: 0, + }), ); } - - 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(()) + } + 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 0000000000..0d4ec6ce35 --- /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 af33c738ce..a48b2849ae 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 a000fbd92e..98ad2afb8a 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 632bfdca49..ffd517f52a 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 bdeac59af9..bdd01e4299 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 434975216a..95077649a8 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 e44ab3e33a..34c1232712 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/128] 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 | 168 ++++++++++++++++++++--------- script/zed-with-local-servers | 2 +- 6 files changed, 281 insertions(+), 52 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 678e712c7d..dfdb5fe9ed 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 f87b68c1ec..12e02b06ed 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 719e8693d4..64ab03e02d 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 0abf2c44a7..6461f67c38 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 bdd01e4299..bfaa414a27 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" - } 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 - }) + Flex::column() .with_child( - Label::new(text, header_style.text.clone()) - .aligned() - .left() - .flex(1., true), + 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 { + None + }) + .with_child( + Label::new(text, header_style.text.clone()) + .aligned() + .left() + .flex(1., true), + ) + .with_children(button.map(|button| button.aligned().right())) + .constrained() + .with_height(theme.collab_panel.row_height) + .contained() + .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 f1de38adcf..c47b0e3de0 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/128] 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 bfaa414a27..53f7eee79a 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/128] 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 dfdb5fe9ed..99501bbd2a 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 a48b2849ae..1e86cef4cc 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 12e02b06ed..066c93ec71 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 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?; - 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); - } - - 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 64ab03e02d..3a47097f7d 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 6461f67c38..1465c66601 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 98ad2afb8a..e0346dbe7f 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 ffd517f52a..14363b74cf 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 5a27787dbc..93ebb812ad 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 8062a12b83..8202b53fdc 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 edbb89e339..c42ed34de6 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 53f7eee79a..c6940fbd14 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 562536d58c..fff1dc8624 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 8a4a72c268..f49a879dc7 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 d71ddeed83..f6985d6906 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 95077649a8..4fe8b5d0f4 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/128] 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 066c93ec71..58607836cc 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 3a47097f7d..2ffcef454b 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 1465c66601..819a3dc4f6 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/128] 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 c6940fbd14..bca0da6176 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_height(theme.collab_panel.row_height) + .with_max_width(icon_size) + .with_max_height(icon_size) + .aligned() + .constrained() + .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/128] 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 819a3dc4f6..eaa3eb8261 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 bca0da6176..1973ddd9f6 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/128] 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 58607836cc..1a89978c38 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 eaa3eb8261..4d30d17485 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/128] 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 99501bbd2a..5218c56891 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 1a89978c38..ad87266e7d 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 88514ef4f1..c1624f0f2a 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 2ffcef454b..a6249bb548 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 4d30d17485..59a997377e 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 14363b74cf..957e085693 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 f49a879dc7..c4fb5aa653 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 f6985d6906..07d54ce4db 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/128] 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 5218c56891..558570475e 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 ad87266e7d..85f5d5f0b8 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 59a997377e..526f12d812 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 e0346dbe7f..26ca5a008e 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 957e085693..c41ac84d1d 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 1bbceee9af..7e95a7677c 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 1973ddd9f6..406daae0f2 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 3de878118e..96eac81a50 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 8e817add3f..49a343e6c9 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/128] 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 9384e826c0..cb1594e941 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/128] 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 558570475e..534bd0b05a 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 a6249bb548..a1d1a23dc9 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 7e95a7677c..30fcb97506 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 406daae0f2..667e8d3a5c 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 fff1dc8624..aa1b3e5a13 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 c47b0e3de0..e1b224de60 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 485ff73d10..82e9636189 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 49a343e6c9..3390dd51f8 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 a1913027b7..281bd74b21 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/128] 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 3cd868a438..6e58be4f15 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 1e86cef4cc..8ef3e32ea8 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 85f5d5f0b8..36b226b97b 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 526f12d812..15237049c3 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 c41ac84d1d..3999740557 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/128] 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 534bd0b05a..1d3ed24d1b 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 667e8d3a5c..4092351a75 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 aa1b3e5a13..96424114c7 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 96eac81a50..8f0ceeab88 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 0000000000..95ae337cbc --- /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 3390dd51f8..37145d0c46 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/128] 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 1d3ed24d1b..fcd0083c3b 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 4092351a75..daad527979 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 0000000000..0cf24dbaf5 --- /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 c42ed34de6..1e48026f46 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 96424114c7..0000000000 --- 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 c4fb5aa653..1fdeef98f0 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 07d54ce4db..c23bbb23e4 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 8f0ceeab88..c557fbcf52 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 a779f39f57..500a82d1ce 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 95ae337cbc..3eff0e4b9a 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 37145d0c46..ea550dea6b 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/128] 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 fcd0083c3b..a1ee7ad6bc 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 daad527979..34cb4f3e91 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 0cf24dbaf5..164759587d 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/128] 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 15237049c3..7ee2a2ba83 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/128] 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 a1ee7ad6bc..8568317355 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 has_pending_channel_invite(&self, channel_id: ChannelId, user_id: UserId) -> bool { + self.outgoing_invites.contains(&(channel_id, user_id)) + } + pub fn remove_member( &self, - channel_id: ChannelId, - user_id: u64, - cx: &mut ModelContext, + _channel_id: ChannelId, + _user_id: u64, + _cx: &mut ModelContext, ) -> Task> { - todo!() - } - - pub fn channel_members( - &self, - channel_id: ChannelId, - cx: &mut ModelContext, - ) -> Task>>> { - todo!() + 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 36b226b97b..d942b8cab9 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 a1d1a23dc9..e4161d3b55 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 7ee2a2ba83..fdfccea98f 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 26ca5a008e..a8e2a12962 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 3999740557..b4f8477a2d 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 34cb4f3e91..771927c8ac 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 164759587d..e6a3ba9288 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; + }) } - .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; + }) + } + } } 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(); + if let Some((user, _)) = self.matches.get(self.selected_index) { + match self.mode { + Mode::ManageMembers => { + // } - ContactRequestStatus::RequestSent => { - self.user_store - .update(cx, |store, cx| store.remove_contact(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 + .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 1fdeef98f0..602b34529e 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/128] 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 e6a3ba9288..9af6099f65 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 6efa33e961..ef8b75d1b3 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 c557fbcf52..8d0159d7ad 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 3eff0e4b9a..951591676b 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/128] 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 8568317355..1d3bbd4435 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 d942b8cab9..5a2ab24b1e 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; + self.check_user_is_channel_admin(channel_id, inviter_id, &*tx) + .await?; - // 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_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 e4161d3b55..b4c22430e5 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 fdfccea98f..17f1334544 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 9af6099f65..5628540022 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/128] 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 | 15 ++++++--- crates/collab/src/tests/channel_tests.rs | 9 +++--- crates/rpc/proto/zed.proto | 3 +- 7 files changed, 70 insertions(+), 36 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 1d3bbd4435..ee04865e50 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 0d4ec6ce35..7f31243dad 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 5a2ab24b1e..6ebf5933df 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 b4c22430e5..5ffcd12776 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 17f1334544..31b0b2280a 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); - 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?; + 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 @@ -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 b4f8477a2d..abaedb52a8 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 602b34529e..7dd5a0a893 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/128] 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 5ffcd12776..3067fd063e 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/128] 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 ee04865e50..c04b123acf 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 31b0b2280a..6893c4bde4 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 abaedb52a8..43e5a296c4 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 771927c8ac..df27ea5005 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/128] 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 | 75 +++- crates/collab/src/tests/channel_tests.rs | 192 +++++++--- 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, 728 insertions(+), 196 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 0000000000..edd0462678 --- /dev/null +++ b/assets/icons/channels.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index a7f4b55084..d99a660850 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 c04b123acf..51176986ef 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 6ebf5933df..9dc4ad805b 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 3067fd063e..efc35a5c24 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 6893c4bde4..f1fd97db41 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 { - session.peer.send(session.connection_id, update)?; + 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)?; + } } 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 43e5a296c4..ae149f6a8a 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,82 @@ 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!( + 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_b_id, + name: "channel-b".to_string(), + parent_id: Some(channel_a_id), + user_is_admin: false, + depth: 1, + }) + ] + ) + }); + + let channel_c_id = client_a + .channel_store() + .update(cx_a, |channel_store, _| { + channel_store.create_channel("channel-c", Some(channel_b_id)) + }) + .await + .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, + 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, + }), + ] + ) + }); + + // 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!( @@ -137,65 +211,83 @@ async fn test_basic_channels( 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, - }) - ] - ) - }); - - 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, + 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 df27ea5005..a84c5c111e 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 5628540022..0286e30b80 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 + 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.remove_member(self.channel_id, user.id, cx) + store.invite_member(self.channel_id, selected_user.id, false, 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(), - }, + .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 7dd5a0a893..8f187a87c6 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 c23bbb23e4..fac011f803 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 8d0159d7ad..448f6ca5dd 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 951591676b..a097bc561f 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/128] 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 | 336 +++++++++--------- .../src/collab_panel/channel_modal.rs | 4 +- crates/theme/src/theme.rs | 8 +- styles/src/style_tree/collab_panel.ts | 37 +- 8 files changed, 260 insertions(+), 193 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 51176986ef..13510a1e1c 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 9dc4ad805b..c3ffc12634 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 ae149f6a8a..88d88a40fd 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 a84c5c111e..382381dba1 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(), + }); + } + if !participant.video_tracks.is_empty() { + self.entries.push(ListEntry::ParticipantScreen { + peer_id: participant.peer_id, + is_last: true, }); } } - } - // Populate remote participants. - self.match_candidates.clear(); - self.match_candidates - .extend(room.remote_participants().iter().map(|(_, participant)| { - StringMatchCandidate { - id: participant.user.id as usize, - string: participant.user.github_login.clone(), - char_bag: participant.user.github_login.chars().collect(), - } - })); - let matches = executor.block(match_strings( - &self.match_candidates, - &query, - true, - usize::MAX, - &Default::default(), - executor.clone(), - )); - for mat in matches { - let user_id = mat.candidate_id as u64; - let participant = &room.remote_participants()[&user_id]; - 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 0286e30b80..1b1a50dbe4 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 448f6ca5dd..8bd673d1b3 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 ea550dea6b..f24468dca6 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, }, }, + channel_hash: { + color: foreground(layer, "on"), + width: 14, + }, 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: { - color: foreground(layer, "on"), - button_width: 28, - icon_width: 16, - }, + 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/128] 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 d99a660850..11cc50a03e 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 1b1a50dbe4..f1775eb084 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/128] 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 f1775eb084..0671eee8af 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 cec9904eac..e01f81c29e 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 a097bc561f..8dc9e79967 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/128] 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 13510a1e1c..317fbd1189 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 0671eee8af..fc1b86354f 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 8bd673d1b3..c778b5fc88 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 8dc9e79967..40fd497458 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/128] 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 fc1b86354f..8747d9a0af 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 { - Some(proto::channel_member::Kind::Invitee) => Some( - Label::new("cancel invite", theme.cancel_invite_button.text.clone()) + 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.cancel_invite_button.container) - .into_any(), + .with_style(theme.member_icon.container), ), - Some(proto::channel_member::Kind::Member) - | Some(proto::channel_member::Kind::AncestorMember) - | None => None, + 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, }, - 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 c778b5fc88..b3640f538f 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 40fd497458..447522070b 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/128] 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 | 179 +++++++++--------- styles/src/style_tree/collab_panel.ts | 31 +++ 10 files changed, 195 insertions(+), 115 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index c40ed4e8da..08faedbed6 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 957c5e1c06..35d3c9f7ef 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 eb92e0f6e8..04ba8fb946 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 382381dba1..d8e2682316 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 fe3484b782..5e2954b915 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 0383117de8..4d84a1c638 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 6ad321c735..34752ad3c4 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 b3640f538f..c554f77fe4 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 6c88e5032c..e447a43d55 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); + 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(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) + 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 f24468dca6..2c543356b0 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/128] 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 cbd7c8d304..26f71da741 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 2ceac649ec..471608c43e 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 d8e2682316..13d14f51ac 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 b702fb4e02..a5836ff776 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/128] 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 4c2721ffeb..be11d1fb44 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 13d14f51ac..e457f8c750 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 8747d9a0af..7ce830b22f 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/128] 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 e457f8c750..f745420eeb 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/128] 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 7f31243dad..69d5fed70d 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 655f0ec84c..f435d9a721 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/128] 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 7ce830b22f..09be3798a6 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 30fcb97506..b604761488 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/128] 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 | 62 +++++++++++++++------- crates/collab/src/db/tests.rs | 3 ++ crates/collab/src/rpc.rs | 32 +++++++---- crates/collab/src/tests/channel_tests.rs | 67 ++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 33 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 317fbd1189..93b96fc629 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 c3ffc12634..eb40587ea7 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, - })) + let channel = channel::Entity::find_by_id(channel_id).one(&*tx).await?; + + 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 efc35a5c24..cdcde3332c 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 f1fd97db41..a24db6be81 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 88d88a40fd..9723b18394 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/128] 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 93b96fc629..1beb1bc8ea 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 eb40587ea7..ed5e7e8e3d 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 f745420eeb..2b39678f5e 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 c554f77fe4..cf8da6233a 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 c8beb86aef..2766cee295 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 2c543356b0..a859f6d670 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/128] 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 11cc50a03e..f4d36ee95b 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 2b39678f5e..85e0d80cce 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 b0f1a9c6c8..519ad1ecd0 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/128] 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 1beb1bc8ea..ec945ce036 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 2766cee295..c8beb86aef 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/128] 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 85e0d80cce..b3b43dbabe 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))); + .push(cx.observe(&this.user_store, |this, _, cx| { + this.update_entries(false, cx) + })); this.subscriptions - .push(cx.observe(&this.channel_store, |this, _, cx| this.update_entries(cx))); + .push(cx.observe(&this.channel_store, |this, _, cx| { + this.update_entries(false, 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))); + .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/128] 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 b3b43dbabe..c934313621 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 cf8da6233a..1756f91fb8 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 a859f6d670..fd6e75d9ec 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/128] 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 ec945ce036..8fb005a262 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/128] 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 6703f98df2..3dceaecef4 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 2d94cb6d97..df981838bf 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 ed5e7e8e3d..d830938497 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 b0072155a3..08ecbddb56 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/128] 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 c669e1da40..31d7b629f8 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/128] 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 8fb005a262..b9aa4268cd 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 69d5fed70d..4ee54d3eca 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 d830938497..8faea0e402 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 cdcde3332c..a659f3d164 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 a24db6be81..0f52c8c03a 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 9723b18394..b2e9cae08a 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 dd2a0db243..7bf2290622 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 8f187a87c6..13b4c60aad 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 fac011f803..d3a3091131 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/128] 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 4ee54d3eca..f74169eb2a 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 8faea0e402..b7718be118 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 a659f3d164..2680d81aac 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 0f52c8c03a..07d343959f 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/128] 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 b9aa4268cd..6325bc1a30 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 07d343959f..c2f0d31f90 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 b2e9cae08a..63fab0d5f8 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/128] 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 fd6e75d9ec..0979760b88 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/128] 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 | 234 ++++++++++++++++------- crates/rpc/proto/zed.proto | 18 +- crates/rpc/src/proto.rs | 6 +- styles/src/style_tree/collab_panel.ts | 2 +- 8 files changed, 260 insertions(+), 110 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 6325bc1a30..206423579a 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 c2f0d31f90..f9f2d4a2e2 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 31d7b629f8..46cbcb0213 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 63fab0d5f8..0dc6d478d1 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 7bf2290622..cb40d496b6 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(()) - }) - .detach(); - } - ChannelEditingState::Rename { channel_id } => { + 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.rename(channel_id, &channel_name, cx) + channel_store.create_channel(&channel_name, *parent_id, cx) }) .detach(); + cx.notify(); + } + 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) + }) + .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 13b4c60aad..fc9a66753c 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 d3a3091131..92732b00b5 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 0979760b88..6c10da7482 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/128] 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 09be3798a6..77401d269c 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 1756f91fb8..9025bf1cd2 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 0000000000..52d0b42d97 --- /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 58b2a1cbf2..3311081a6f 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 447522070b..764ab9fc93 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/128] 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 77401d269c..f72eafe7da 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 9025bf1cd2..f455cfca73 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 764ab9fc93..d09ab2db7b 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/128] 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 c21c26e0ef..b0621743fd 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 6c10da7482..627d5868b6 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/128] 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 869c159c42..b71749121d 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 f455cfca73..f9c7f37baf 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 627d5868b6..a6ff3c68d5 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/128] 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 b71749121d..ed042dbf4e 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 f9c7f37baf..cd31e312d4 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 a6ff3c68d5..3df2dd13d2 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/128] 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 08faedbed6..2ddf4a137f 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/128] 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 ed042dbf4e..0e99497cef 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 f72eafe7da..12c923594f 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 41fff2af43..4cc7034f49 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 cd31e312d4..1e11fbbf82 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 struct TabbedModal { + pub tab_button: Toggleable>, + pub modal: ContainerStyle, + pub header: ContainerStyle, + pub body: ContainerStyle, + pub title: ContainedText, + pub picker: Picker, pub max_height: f32, pub max_width: f32, - pub title: ContainedText, - pub mode_button: Toggleable>, - pub picker: Picker, 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 384b622469..8be8ad2bde 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 be6d4d42bf..ee5e19e111 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 b0621743fd..0000000000 --- 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 0000000000..95690b5d85 --- /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 3df2dd13d2..06170901e9 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 aa88a9f26a..04f95cc367 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/128] 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 0e99497cef..274eeb9f2d 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 1e11fbbf82..4919eb93c7 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 06170901e9..8f8b8e504f 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/128] 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 0000000000..ba72851768 --- /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 6887fc7c30..ae3fa763e7 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 3311081a6f..c7bdb26e7b 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/128] Style channels panel items --- styles/src/component/text_button.ts | 4 +- styles/src/style_tree/collab_panel.ts | 247 +++++++++----------------- 2 files changed, 90 insertions(+), 161 deletions(-) diff --git a/styles/src/component/text_button.ts b/styles/src/component/text_button.ts index c7bdb26e7b..2be2dd19cb 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 8f8b8e504f..b8969e2b9a 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"), }, }, }), + 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: { + bottom: 4, + left: 8, + right: 8, + top: 4, + }, + margin: { + left: SPACING, + right: SPACING, + }, + } + + return { + ...collab_modals(), + log_in_button: text_button(), background: background(layer), padding: { - top: 12, - }, - 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, - }, - }, - channel_hash: { - color: foreground(layer, "on"), - width: 14, + 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/128] 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 0000000000..fa046c6050 --- /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 0000000000..186c9c7457 --- /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 0000000000..7bae7f4801 --- /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 0000000000..b971555cfa --- /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 0000000000..8e61beed5d --- /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 0000000000..fcd9d83fc2 --- /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 0000000000..171cdd61c0 --- /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 0000000000..fe8ad03dda --- /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 0000000000..06dbf178ae --- /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 0000000000..4aa44979c3 --- /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 0000000000..82b9401d08 --- /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 0000000000..7e45535773 --- /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 0000000000..2703f70119 --- /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 0000000000..80ce656f57 --- /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 0000000000..1858c65520 --- /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 0000000000..0b539adb6c --- /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 0000000000..82f4529c1b --- /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 0000000000..69ba8eb9e6 --- /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 0000000000..4dc7755714 --- /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 0000000000..8974fd939d --- /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 0000000000..d8941ee1f0 --- /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 0000000000..a54dd0ad66 --- /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 0000000000..525109db4c --- /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 0000000000..af10921891 --- /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 0000000000..4838e82242 --- /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 0000000000..ba751411af --- /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 0000000000..49e097b023 --- /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 0000000000..4c131466c2 --- /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 0000000000..85450cdc43 --- /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 0000000000..15dd705b0b --- /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 0000000000..6b3d0fd41e --- /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 0000000000..31c5aa31a6 --- /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/128] 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 0000000000..f685245ed3 --- /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 0000000000..1e676fe313 --- /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 0000000000..652f45a7e8 --- /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 274eeb9f2d..8c63649ef9 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 b8969e2b9a..648fa141a5 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/128] 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 0000000000..3a078fb53f --- /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 648fa141a5..6cf6f9b095 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/128] 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 52d0b42d97..cadfcc8d4a 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 0000000000..9938fb9311 --- /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 95690b5d85..c0bf358e71 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/128] 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 d197f57fa5..4b9768c07c 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 b7e1259b2c..12dfe59864 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 2277fd5dfb..7141cda172 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 970e0115df..08a1d08633 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 d35b721c6c..2d3b81f7c2 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/128] Ensure channels are sorted alphabetically Co-authored-by: Mikayla --- crates/client/src/channel_store.rs | 186 +++++---- crates/client/src/channel_store_tests.rs | 92 ++++- crates/collab/src/tests/channel_tests.rs | 474 ++++++++++++----------- crates/collab_ui/src/collab_panel.rs | 72 ++-- 4 files changed, 477 insertions(+), 347 deletions(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index 206423579a..8217e6cbc8 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; + 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 } - 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; - } + }) } 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)); - - for channel in payload.channel_invitations { - if let Some(existing_channel) = self - .channel_invitations - .iter_mut() - .find(|c| c.id == channel.id) - { - let existing_channel = Arc::make_mut(existing_channel); - 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, - }), - ); + if !payload.remove_channel_invitations.is_empty() { + self.channel_invitations + .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id)); } - - 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; - } - - 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, - }), - ); - } - } else { - self.channels.insert( - 0, + for channel in payload.channel_invitations { + match self + .channel_invitations + .binary_search_by_key(&channel.id, |c| c.id) + { + 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, + }), + ), + } + } + + 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)); + } + + 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; + } + 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 f74169eb2a..3a3f3842eb 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 0dc6d478d1..f1157ce7ae 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, - - depth: 0, - })], - ); - }); + 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 8c63649ef9..563cc942da 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/128] 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 f875c71fe6..60488d04cf 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/128] 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 683ff6f4df..5a4bc8329f 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 97881f9a50..22f294d3fc 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 1e48026f46..f2ba35967f 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/128] 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 563cc942da..e4838df939 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 ba9b61c98b..a86b257686 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 4919eb93c7..e081b70047 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 6cf6f9b095..1d1e09075e 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/128] 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 8217e6cbc8..e2c18a63a9 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 3a3f3842eb..51e819349e 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 f9f2d4a2e2..2396085a01 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 f1157ce7ae..d4cf6423f0 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 e4838df939..eaa3560b9b 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/128] 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 2cba729111..665779fb98 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/128] 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 665779fb98..0665ecf75b 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/128] 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 0665ecf75b..2b79f5a125 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/128] 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 48d3a7a0ec..3e4f274f23 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 4cc7034f49..539e041ae7 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/128] 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 2b79f5a125..3f303da2af 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 e081b70047..912ca0e8b8 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 1d1e09075e..c65887e17c 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/128] 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 0000000000..77b180892c --- /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 0000000000..85ba2e1f37 --- /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 d1a32c72f1..89b4469d42 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/128] 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 3f303da2af..7ad7a8883d 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/128] 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 6e58be4f15..17540062e4 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 5a4bc8329f..a4ffa8866e 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 2396085a01..183aab8496 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/128] 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 7ad7a8883d..4c20411549 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/128] 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 ab5d7382c7..ff8d835edc 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/128] 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 17540062e4..33ba7a2ab9 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 a4ffa8866e..6f01b1d757 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 b7718be118..64349123af 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 2680d81aac..dbbf162d12 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 183aab8496..521aa3e7b4 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 d4cf6423f0..d778b6a472 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 4c20411549..498b278abd 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 fc9a66753c..caa5efd2cb 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/128] 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 498b278abd..ce4ffc8f6b 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 d4266a71fe..84688c0971 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/128] 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 fa046c6050..5b3faaa9cc 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 9dc17e2ec5..e0fe41aebe 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/128] 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 ce4ffc8f6b..f113f12f9d 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/128] 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 233b0f62aa..d80fb6738f 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 33ba7a2ab9..3ac29bfc85 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 80c779b95e610ade8258cf5dcf28fa4f8ac7cd2c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 16 Aug 2023 15:06:42 +0300 Subject: [PATCH 103/128] Focus terminal view on mouse click in terminal Before, terminal view focused the parent (pane) instead and, if terminal's search bar was open and focused, pane transferred the focus back --- crates/terminal_view/src/terminal_element.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 232d3c5535..0ac189db0b 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -400,7 +400,8 @@ impl TerminalElement { region = region // Start selections .on_down(MouseButton::Left, move |event, v: &mut TerminalView, cx| { - cx.focus_parent(); + let terminal_view = cx.handle(); + cx.focus(&terminal_view); v.context_menu.update(cx, |menu, _cx| menu.delay_cancel()); if let Some(conn_handle) = connection.upgrade(cx) { conn_handle.update(cx, |terminal, cx| { From 6c15636ccccdad1cd3db0da1efeacd4ef6538011 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 12:38:44 -0400 Subject: [PATCH 104/128] 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 ba72851768..3b554ae37a 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 ae3fa763e7..1a2d0bcec4 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 0000000000..3f1c54a7f6 --- /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 2be2dd19cb..b911cd5b77 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 81c911c7bd..d41b4e2cc3 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 c3cde46d65..25217444da 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 c65887e17c..61c96ad75a 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 105/128] 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 0000000000..1858c65520 --- /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 3e4f274f23..75ab40be85 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 912ca0e8b8..69fa7a09b3 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 c0bf358e71..4bdeb45f9c 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 106/128] 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 f113f12f9d..4f0a61bf6a 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 61c96ad75a..2d8c050838 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, From 442ec606d0add77138bfefe277464c08ea8258a4 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 16 Aug 2023 11:05:08 -0700 Subject: [PATCH 107/128] collab 0.17.0 --- Cargo.lock | 2 +- crates/collab/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb210eb797..0ccdf4c783 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1479,7 +1479,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "async-tungstenite", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index c61fdeebfb..fc8c1644cd 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.16.0" +version = "0.17.0" publish = false [[bin]] From 07675e3c559b32182256de16958bc418667f437f Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 16 Aug 2023 14:22:54 -0400 Subject: [PATCH 108/128] v0.101.x dev --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ccdf4c783..1b54bdda02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9863,7 +9863,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.100.0" +version = "0.101.0" dependencies = [ "activity_indicator", "ai", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1a2575dd5f..d0aebb15e7 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.100.0" +version = "0.101.0" publish = false [lib] From 5c3d563f0fb5093154d589851ef9d45bc7f154de Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Aug 2023 20:24:06 +0300 Subject: [PATCH 109/128] Draft quick actions bar --- crates/editor/src/editor.rs | 33 +++++- crates/editor/src/inlay_hint_cache.rs | 2 +- crates/search/src/buffer_search.rs | 2 +- crates/theme/src/theme.rs | 1 + crates/zed/src/quick_action_bar.rs | 143 ++++++++++++++++++++++++++ crates/zed/src/zed.rs | 7 ++ styles/src/style_tree/workspace.ts | 4 + 7 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 crates/zed/src/quick_action_bar.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 256ef2284c..3c6103cd90 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -302,10 +302,11 @@ actions!( Hover, Format, ToggleSoftWrap, + ToggleInlays, RevealInFinder, CopyPath, CopyRelativePath, - CopyHighlightJson + CopyHighlightJson, ] ); @@ -446,6 +447,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(Editor::toggle_code_actions); cx.add_action(Editor::open_excerpts); cx.add_action(Editor::toggle_soft_wrap); + cx.add_action(Editor::toggle_inlays); cx.add_action(Editor::reveal_in_finder); cx.add_action(Editor::copy_path); cx.add_action(Editor::copy_relative_path); @@ -1238,6 +1240,7 @@ enum GotoDefinitionKind { #[derive(Debug, Clone)] enum InlayRefreshReason { + Toggled(bool), SettingsChange(InlayHintSettings), NewLinesShown, BufferEdited(HashSet>), @@ -2669,12 +2672,40 @@ impl Editor { } } + pub fn toggle_inlays(&mut self, _: &ToggleInlays, cx: &mut ViewContext) { + self.inlay_hint_cache.enabled = !self.inlay_hint_cache.enabled; + self.refresh_inlays( + InlayRefreshReason::Toggled(self.inlay_hint_cache.enabled), + cx, + ) + } + + pub fn inlays_enabled(&self) -> bool { + self.inlay_hint_cache.enabled + } + fn refresh_inlays(&mut self, reason: InlayRefreshReason, cx: &mut ViewContext) { if self.project.is_none() || self.mode != EditorMode::Full { return; } let (invalidate_cache, required_languages) = match reason { + InlayRefreshReason::Toggled(enabled) => { + if enabled { + (InvalidationStrategy::RefreshRequested, None) + } else { + self.inlay_hint_cache.clear(); + self.splice_inlay_hints( + self.visible_inlay_hints(cx) + .iter() + .map(|inlay| inlay.id) + .collect(), + Vec::new(), + cx, + ); + return; + } + } InlayRefreshReason::SettingsChange(new_settings) => { match self.inlay_hint_cache.update_settings( &self.buffer, diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 8be72aec46..4327ff0d73 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -380,7 +380,7 @@ impl InlayHintCache { } } - fn clear(&mut self) { + pub fn clear(&mut self) { self.version += 1; self.update_tasks.clear(); self.hints.clear(); diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 36c9d3becd..fc3c78afe2 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -553,7 +553,7 @@ impl BufferSearchBar { .into_any() } - fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { + pub fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { let mut propagate_action = true; if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |search_bar, cx| { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 69fa7a09b3..30a2e8caec 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -399,6 +399,7 @@ pub struct Toolbar { pub height: f32, pub item_spacing: f32, pub nav_button: Interactive, + pub toggleable_tool: Toggleable>, } #[derive(Clone, Deserialize, Default, JsonSchema)] diff --git a/crates/zed/src/quick_action_bar.rs b/crates/zed/src/quick_action_bar.rs new file mode 100644 index 0000000000..28dba9399f --- /dev/null +++ b/crates/zed/src/quick_action_bar.rs @@ -0,0 +1,143 @@ +use editor::Editor; +use gpui::{ + elements::{Empty, Flex, MouseEventHandler, ParentElement, Svg}, + platform::{CursorStyle, MouseButton}, + Action, AnyElement, Element, Entity, EventContext, View, ViewContext, ViewHandle, +}; + +use search::{buffer_search, BufferSearchBar}; +use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView, Workspace}; + +pub struct QuickActionBar { + workspace: ViewHandle, + active_item: Option>, +} + +impl QuickActionBar { + pub fn new(workspace: ViewHandle) -> Self { + Self { + workspace, + active_item: None, + } + } + + fn active_editor(&self) -> Option> { + self.active_item + .as_ref() + .and_then(|item| item.downcast::()) + } +} + +impl Entity for QuickActionBar { + type Event = (); +} + +impl View for QuickActionBar { + fn ui_name() -> &'static str { + "QuickActionsBar" + } + + fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { + let Some(editor) = self.active_editor() else { return Empty::new().into_any(); }; + + let inlays_enabled = editor.read(cx).inlays_enabled(); + let mut bar = Flex::row().with_child(render_quick_action_bar_button( + 0, + "icons/hamburger_15.svg", + inlays_enabled, + ( + "Toggle inlays".to_string(), + Some(Box::new(editor::ToggleInlays)), + ), + cx, + |this, cx| { + if let Some(editor) = this.active_editor() { + editor.update(cx, |editor, cx| { + editor.toggle_inlays(&editor::ToggleInlays, cx); + }); + } + }, + )); + + if editor.read(cx).buffer().read(cx).is_singleton() { + let search_action = buffer_search::Deploy { focus: true }; + + // TODO kb: this opens the search bar in a differently focused pane (should be the same) + should be toggleable + let pane = self.workspace.read(cx).active_pane().clone(); + bar = bar.with_child(render_quick_action_bar_button( + 1, + "icons/magnifying_glass_12.svg", + false, + ( + "Search in buffer".to_string(), + Some(Box::new(search_action.clone())), + ), + cx, + move |_, cx| { + pane.update(cx, |pane, cx| { + BufferSearchBar::deploy(pane, &search_action, cx); + }); + }, + )); + } + + bar.into_any() + } +} + +fn render_quick_action_bar_button< + F: 'static + Fn(&mut QuickActionBar, &mut EventContext), +>( + index: usize, + icon: &'static str, + toggled: bool, + tooltip: (String, Option>), + cx: &mut ViewContext, + on_click: F, +) -> AnyElement { + enum QuickActionBarButton {} + + let theme = theme::current(cx); + let (tooltip_text, action) = tooltip; + + MouseEventHandler::::new(index, cx, |mouse_state, _| { + let style = theme + .workspace + .toolbar + .toggleable_tool + .in_state(toggled) + .style_for(mouse_state); + Svg::new(icon) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, pane, cx| on_click(pane, cx)) + .with_tooltip::(index, tooltip_text, action, theme.tooltip.clone(), cx) + .into_any_named("quick action bar button") +} + +impl ToolbarItemView for QuickActionBar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut ViewContext, + ) -> ToolbarItemLocation { + match active_pane_item { + Some(active_item) => { + dbg!("@@@@@@@@@@ TODO kb", active_item.id()); + self.active_item = Some(active_item.boxed_clone()); + ToolbarItemLocation::PrimaryRight { flex: None } + } + None => { + self.active_item = None; + ToolbarItemLocation::Hidden + } + } + } +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1c57174fe2..5ff453484c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5,6 +5,8 @@ pub mod only_instance; #[cfg(any(test, feature = "test-support"))] pub mod test; +mod quick_action_bar; + use ai::AssistantPanel; use anyhow::Context; use assets::Assets; @@ -30,6 +32,7 @@ use gpui::{ pub use lsp; pub use project; use project_panel::ProjectPanel; +use quick_action_bar::QuickActionBar; use search::{BufferSearchBar, ProjectSearchBar}; use serde::Deserialize; use serde_json::to_string_pretty; @@ -255,12 +258,16 @@ pub fn initialize_workspace( workspace_handle.update(&mut cx, |workspace, cx| { let workspace_handle = cx.handle(); cx.subscribe(&workspace_handle, { + let workspace_handle = workspace_handle.clone(); move |workspace, _, event, cx| { if let workspace::Event::PaneAdded(pane) = event { pane.update(cx, |pane, cx| { pane.toolbar().update(cx, |toolbar, cx| { let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(workspace)); toolbar.add_item(breadcrumbs, cx); + let quick_action_bar = + cx.add_view(|_| QuickActionBar::new(workspace_handle.clone())); + toolbar.add_item(quick_action_bar, cx); let buffer_search_bar = cx.add_view(BufferSearchBar::new); toolbar.add_item(buffer_search_bar, cx); let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); diff --git a/styles/src/style_tree/workspace.ts b/styles/src/style_tree/workspace.ts index 5aee3c987d..4d44166eb8 100644 --- a/styles/src/style_tree/workspace.ts +++ b/styles/src/style_tree/workspace.ts @@ -12,6 +12,7 @@ import tabBar from "./tab_bar" import { interactive } from "../element" import { titlebar } from "./titlebar" import { useTheme } from "../theme" +import { toggleable_icon_button } from "../component/icon_button" export default function workspace(): any { const theme = useTheme() @@ -149,6 +150,9 @@ export default function workspace(): any { }, }, }), + toggleable_tool: toggleable_icon_button(theme, { + active_color: "accent", + }), padding: { left: 8, right: 8, top: 4, bottom: 4 }, }, breadcrumb_height: 24, From 6a326c1bd86a32ea9d4d0f4c9baa05773c60b1c0 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 10 Aug 2023 00:15:37 +0300 Subject: [PATCH 110/128] Toggle buffer search via quick actions --- crates/zed/src/quick_action_bar.rs | 42 +++++++++++++++++++++--------- crates/zed/src/zed.rs | 7 +++-- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/crates/zed/src/quick_action_bar.rs b/crates/zed/src/quick_action_bar.rs index 28dba9399f..c133f5ece4 100644 --- a/crates/zed/src/quick_action_bar.rs +++ b/crates/zed/src/quick_action_bar.rs @@ -6,17 +6,17 @@ use gpui::{ }; use search::{buffer_search, BufferSearchBar}; -use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView, Workspace}; +use workspace::{item::ItemHandle, Pane, ToolbarItemLocation, ToolbarItemView}; pub struct QuickActionBar { - workspace: ViewHandle, + pane: ViewHandle, active_item: Option>, } impl QuickActionBar { - pub fn new(workspace: ViewHandle) -> Self { + pub fn new(pane: ViewHandle) -> Self { Self { - workspace, + pane, active_item: None, } } @@ -60,23 +60,40 @@ impl View for QuickActionBar { )); if editor.read(cx).buffer().read(cx).is_singleton() { + let buffer_search_bar = self + .pane + .read(cx) + .toolbar() + .read(cx) + .item_of_type::(); + let search_bar_shown = buffer_search_bar + .as_ref() + .map(|bar| !bar.read(cx).is_dismissed()) + .unwrap_or(false); + let search_action = buffer_search::Deploy { focus: true }; - // TODO kb: this opens the search bar in a differently focused pane (should be the same) + should be toggleable - let pane = self.workspace.read(cx).active_pane().clone(); bar = bar.with_child(render_quick_action_bar_button( 1, "icons/magnifying_glass_12.svg", - false, + search_bar_shown, ( - "Search in buffer".to_string(), + "Toggle buffer search".to_string(), Some(Box::new(search_action.clone())), ), cx, - move |_, cx| { - pane.update(cx, |pane, cx| { - BufferSearchBar::deploy(pane, &search_action, cx); - }); + move |this, cx| { + if search_bar_shown { + if let Some(buffer_search_bar) = buffer_search_bar.as_ref() { + buffer_search_bar.update(cx, |buffer_search_bar, cx| { + buffer_search_bar.dismiss(&buffer_search::Dismiss, cx); + }); + } + } else { + this.pane.update(cx, |pane, cx| { + BufferSearchBar::deploy(pane, &search_action, cx); + }); + } }, )); } @@ -130,7 +147,6 @@ impl ToolbarItemView for QuickActionBar { ) -> ToolbarItemLocation { match active_pane_item { Some(active_item) => { - dbg!("@@@@@@@@@@ TODO kb", active_item.id()); self.active_item = Some(active_item.boxed_clone()); ToolbarItemLocation::PrimaryRight { flex: None } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 5ff453484c..47dff41729 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -258,15 +258,14 @@ pub fn initialize_workspace( workspace_handle.update(&mut cx, |workspace, cx| { let workspace_handle = cx.handle(); cx.subscribe(&workspace_handle, { - let workspace_handle = workspace_handle.clone(); move |workspace, _, event, cx| { - if let workspace::Event::PaneAdded(pane) = event { - pane.update(cx, |pane, cx| { + if let workspace::Event::PaneAdded(pane_handle) = event { + pane_handle.update(cx, |pane, cx| { pane.toolbar().update(cx, |toolbar, cx| { let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(workspace)); toolbar.add_item(breadcrumbs, cx); let quick_action_bar = - cx.add_view(|_| QuickActionBar::new(workspace_handle.clone())); + cx.add_view(|_| QuickActionBar::new(pane_handle.clone())); toolbar.add_item(quick_action_bar, cx); let buffer_search_bar = cx.add_view(BufferSearchBar::new); toolbar.add_item(buffer_search_bar, cx); From 0b93e490a550693f4b65faf61d30c8ae14943cb2 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 10 Aug 2023 12:28:17 +0300 Subject: [PATCH 111/128] Improve toggle UI, fix inlays update speed --- crates/editor/src/editor.rs | 11 ++++++----- crates/zed/src/quick_action_bar.rs | 10 +++++----- styles/src/style_tree/workspace.ts | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3c6103cd90..ef02cee3d0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1240,7 +1240,7 @@ enum GotoDefinitionKind { #[derive(Debug, Clone)] enum InlayRefreshReason { - Toggled(bool), + Toggle(bool), SettingsChange(InlayHintSettings), NewLinesShown, BufferEdited(HashSet>), @@ -2673,11 +2673,10 @@ impl Editor { } pub fn toggle_inlays(&mut self, _: &ToggleInlays, cx: &mut ViewContext) { - self.inlay_hint_cache.enabled = !self.inlay_hint_cache.enabled; self.refresh_inlays( - InlayRefreshReason::Toggled(self.inlay_hint_cache.enabled), + InlayRefreshReason::Toggle(!self.inlay_hint_cache.enabled), cx, - ) + ); } pub fn inlays_enabled(&self) -> bool { @@ -2690,7 +2689,8 @@ impl Editor { } let (invalidate_cache, required_languages) = match reason { - InlayRefreshReason::Toggled(enabled) => { + InlayRefreshReason::Toggle(enabled) => { + self.inlay_hint_cache.enabled = enabled; if enabled { (InvalidationStrategy::RefreshRequested, None) } else { @@ -2805,6 +2805,7 @@ impl Editor { self.display_map.update(cx, |display_map, cx| { display_map.splice_inlays(to_remove, to_insert, cx); }); + cx.notify(); } fn trigger_on_type_formatting( diff --git a/crates/zed/src/quick_action_bar.rs b/crates/zed/src/quick_action_bar.rs index c133f5ece4..6157ca9c47 100644 --- a/crates/zed/src/quick_action_bar.rs +++ b/crates/zed/src/quick_action_bar.rs @@ -45,10 +45,7 @@ impl View for QuickActionBar { 0, "icons/hamburger_15.svg", inlays_enabled, - ( - "Toggle inlays".to_string(), - Some(Box::new(editor::ToggleInlays)), - ), + ("Inlays".to_string(), Some(Box::new(editor::ToggleInlays))), cx, |this, cx| { if let Some(editor) = this.active_editor() { @@ -78,7 +75,8 @@ impl View for QuickActionBar { "icons/magnifying_glass_12.svg", search_bar_shown, ( - "Toggle buffer search".to_string(), + "Buffer search".to_string(), + // TODO kb no keybinding is shown for search + toggle inlays does not update icon color Some(Box::new(search_action.clone())), ), cx, @@ -132,6 +130,8 @@ fn render_quick_action_bar_button< .constrained() .with_width(style.button_width) .with_height(style.button_width) + .contained() + .with_style(style.container) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, pane, cx| on_click(pane, cx)) diff --git a/styles/src/style_tree/workspace.ts b/styles/src/style_tree/workspace.ts index 4d44166eb8..578dd23c6e 100644 --- a/styles/src/style_tree/workspace.ts +++ b/styles/src/style_tree/workspace.ts @@ -151,6 +151,7 @@ export default function workspace(): any { }, }), toggleable_tool: toggleable_icon_button(theme, { + margin: { left: 8 }, active_color: "accent", }), padding: { left: 8, right: 8, top: 4, bottom: 4 }, From 0f650acc23a89239bb788801ffe40d8e6fdf3c5b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 10 Aug 2023 13:30:12 +0300 Subject: [PATCH 112/128] Repaint inlays icon on inlays cache disabling/enabling Co-Authored-By: Mikayla Maki --- crates/editor/src/inlay_hint_cache.rs | 2 +- crates/gpui/src/keymap_matcher/binding.rs | 8 ++++++-- crates/zed/src/quick_action_bar.rs | 21 ++++++++++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 4327ff0d73..3385546971 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -24,7 +24,7 @@ pub struct InlayHintCache { hints: HashMap>>, allowed_hint_kinds: HashSet>, version: usize, - enabled: bool, + pub(super) enabled: bool, update_tasks: HashMap, } diff --git a/crates/gpui/src/keymap_matcher/binding.rs b/crates/gpui/src/keymap_matcher/binding.rs index 527052c85d..f7296d50fb 100644 --- a/crates/gpui/src/keymap_matcher/binding.rs +++ b/crates/gpui/src/keymap_matcher/binding.rs @@ -88,8 +88,12 @@ impl Binding { action: &dyn Action, contexts: &[KeymapContext], ) -> Option> { - if self.action.eq(action) && self.match_context(contexts) { - Some(self.keystrokes.clone()) + if self.action.eq(action) { + if self.match_context(contexts) { + Some(self.keystrokes.clone()) + } else { + None + } } else { None } diff --git a/crates/zed/src/quick_action_bar.rs b/crates/zed/src/quick_action_bar.rs index 6157ca9c47..245983dc49 100644 --- a/crates/zed/src/quick_action_bar.rs +++ b/crates/zed/src/quick_action_bar.rs @@ -2,7 +2,7 @@ use editor::Editor; use gpui::{ elements::{Empty, Flex, MouseEventHandler, ParentElement, Svg}, platform::{CursorStyle, MouseButton}, - Action, AnyElement, Element, Entity, EventContext, View, ViewContext, ViewHandle, + Action, AnyElement, Element, Entity, EventContext, Subscription, View, ViewContext, ViewHandle, }; use search::{buffer_search, BufferSearchBar}; @@ -11,6 +11,7 @@ use workspace::{item::ItemHandle, Pane, ToolbarItemLocation, ToolbarItemView}; pub struct QuickActionBar { pane: ViewHandle, active_item: Option>, + _inlays_enabled_subscription: Option, } impl QuickActionBar { @@ -18,6 +19,7 @@ impl QuickActionBar { Self { pane, active_item: None, + _inlays_enabled_subscription: None, } } @@ -76,7 +78,6 @@ impl View for QuickActionBar { search_bar_shown, ( "Buffer search".to_string(), - // TODO kb no keybinding is shown for search + toggle inlays does not update icon color Some(Box::new(search_action.clone())), ), cx, @@ -143,11 +144,25 @@ impl ToolbarItemView for QuickActionBar { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn ItemHandle>, - _: &mut ViewContext, + cx: &mut ViewContext, ) -> ToolbarItemLocation { match active_pane_item { Some(active_item) => { self.active_item = Some(active_item.boxed_clone()); + self._inlays_enabled_subscription.take(); + + if let Some(editor) = active_item.downcast::() { + let mut inlays_enabled = editor.read(cx).inlays_enabled(); + self._inlays_enabled_subscription = + Some(cx.observe(&editor, move |_, editor, cx| { + let new_inlays_enabled = editor.read(cx).inlays_enabled(); + if inlays_enabled != new_inlays_enabled { + inlays_enabled = new_inlays_enabled; + cx.notify(); + } + })); + } + ToolbarItemLocation::PrimaryRight { flex: None } } None => { From 8926c23bdbed957369e517af897264f8dae930e6 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 11 Aug 2023 13:23:27 +0300 Subject: [PATCH 113/128] Extract quick_action_bar into its own crate --- Cargo.lock | 12 ++++++++++ crates/quick_action_bar/Cargo.toml | 22 +++++++++++++++++++ .../src/quick_action_bar.rs | 0 crates/zed/Cargo.toml | 1 + crates/zed/src/zed.rs | 2 -- 5 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 crates/quick_action_bar/Cargo.toml rename crates/{zed => quick_action_bar}/src/quick_action_bar.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 1b54bdda02..51ca31bd9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5714,6 +5714,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_action_bar" +version = "0.1.0" +dependencies = [ + "editor", + "gpui", + "search", + "theme", + "workspace", +] + [[package]] name = "quote" version = "1.0.32" @@ -9922,6 +9933,7 @@ dependencies = [ "project", "project_panel", "project_symbols", + "quick_action_bar", "rand 0.8.5", "recent_projects", "regex", diff --git a/crates/quick_action_bar/Cargo.toml b/crates/quick_action_bar/Cargo.toml new file mode 100644 index 0000000000..6953ac0e02 --- /dev/null +++ b/crates/quick_action_bar/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "quick_action_bar" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/quick_action_bar.rs" +doctest = false + +[dependencies] +editor = { path = "../editor" } +gpui = { path = "../gpui" } +search = { path = "../search" } +theme = { path = "../theme" } +workspace = { path = "../workspace" } + +[dev-dependencies] +editor = { path = "../editor", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +theme = { path = "../theme", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } diff --git a/crates/zed/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs similarity index 100% rename from crates/zed/src/quick_action_bar.rs rename to crates/quick_action_bar/src/quick_action_bar.rs diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index d0aebb15e7..988648d4b1 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -54,6 +54,7 @@ plugin_runtime = { path = "../plugin_runtime",optional = true } project = { path = "../project" } project_panel = { path = "../project_panel" } project_symbols = { path = "../project_symbols" } +quick_action_bar = { path = "../quick_action_bar" } recent_projects = { path = "../recent_projects" } rpc = { path = "../rpc" } settings = { path = "../settings" } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 47dff41729..8d851909b3 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5,8 +5,6 @@ pub mod only_instance; #[cfg(any(test, feature = "test-support"))] pub mod test; -mod quick_action_bar; - use ai::AssistantPanel; use anyhow::Context; use assets::Assets; From 9c6135f47ad4b37a8d8bab305edc229f0453e8da Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 11 Aug 2023 13:51:14 +0300 Subject: [PATCH 114/128] Test hints toggle --- crates/editor/src/inlay_hint_cache.rs | 127 ++++++++++++++++++++++ crates/gpui/src/keymap_matcher/binding.rs | 8 +- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 3385546971..4c998c3afa 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -2683,6 +2683,127 @@ all hints should be invalidated and requeried for all of its visible excerpts" }); } + #[gpui::test] + async fn test_toggle_inlays(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: false, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + }) + }); + + let (file_with_hints, editor, fake_server) = prepare_test_objects(cx).await; + + editor.update(cx, |editor, cx| { + editor.toggle_inlays(&crate::ToggleInlays, cx) + }); + cx.foreground().start_waiting(); + let lsp_request_count = Arc::new(AtomicU32::new(0)); + let closure_lsp_request_count = Arc::clone(&lsp_request_count); + fake_server + .handle_request::(move |params, _| { + let task_lsp_request_count = Arc::clone(&closure_lsp_request_count); + async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path(file_with_hints).unwrap(), + ); + + let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst) + 1; + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }) + .next() + .await; + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Should display inlays after toggle despite them disabled in settings" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + editor.inlay_hint_cache().version, + 1, + "First toggle should be cache's first update" + ); + }); + + editor.update(cx, |editor, cx| { + editor.toggle_inlays(&crate::ToggleInlays, cx) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + assert!( + cached_hint_labels(editor).is_empty(), + "Should clear hints after 2nd toggle" + ); + assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!(editor.inlay_hint_cache().version, 2); + }); + + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: true, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + }) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Should query LSP hints for the 2nd time after enabling hints in settings" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, 3); + }); + + editor.update(cx, |editor, cx| { + editor.toggle_inlays(&crate::ToggleInlays, cx) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + assert!( + cached_hint_labels(editor).is_empty(), + "Should clear hints after enabling in settings and a 3rd toggle" + ); + assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!(editor.inlay_hint_cache().version, 4); + }); + + editor.update(cx, |editor, cx| { + editor.toggle_inlays(&crate::ToggleInlays, cx) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["3".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Should query LSP hints for the 3rd time after enabling hints in settings and toggling them back on" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, 5); + }); + } + pub(crate) fn init_test(cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { cx.foreground().forbid_parking(); @@ -2759,6 +2880,12 @@ all hints should be invalidated and requeried for all of its visible excerpts" .downcast::() .unwrap(); + editor.update(cx, |editor, cx| { + assert!(cached_hint_labels(editor).is_empty()); + assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!(editor.inlay_hint_cache().version, 0); + }); + ("/a/main.rs", editor, fake_server) } diff --git a/crates/gpui/src/keymap_matcher/binding.rs b/crates/gpui/src/keymap_matcher/binding.rs index f7296d50fb..527052c85d 100644 --- a/crates/gpui/src/keymap_matcher/binding.rs +++ b/crates/gpui/src/keymap_matcher/binding.rs @@ -88,12 +88,8 @@ impl Binding { action: &dyn Action, contexts: &[KeymapContext], ) -> Option> { - if self.action.eq(action) { - if self.match_context(contexts) { - Some(self.keystrokes.clone()) - } else { - None - } + if self.action.eq(action) && self.match_context(contexts) { + Some(self.keystrokes.clone()) } else { None } From 1938fd85e8029830af0760a588a742ea0e1457b2 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 11 Aug 2023 14:42:56 +0300 Subject: [PATCH 115/128] Do not leak pane handles --- .../quick_action_bar/src/quick_action_bar.rs | 36 ++++++------------- crates/search/src/buffer_search.rs | 25 ++++++++----- crates/zed/src/zed.rs | 12 +++---- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index 245983dc49..b506f8dc17 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -6,18 +6,18 @@ use gpui::{ }; use search::{buffer_search, BufferSearchBar}; -use workspace::{item::ItemHandle, Pane, ToolbarItemLocation, ToolbarItemView}; +use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView}; pub struct QuickActionBar { - pane: ViewHandle, + buffer_search_bar: ViewHandle, active_item: Option>, _inlays_enabled_subscription: Option, } impl QuickActionBar { - pub fn new(pane: ViewHandle) -> Self { + pub fn new(buffer_search_bar: ViewHandle) -> Self { Self { - pane, + buffer_search_bar, active_item: None, _inlays_enabled_subscription: None, } @@ -59,17 +59,7 @@ impl View for QuickActionBar { )); if editor.read(cx).buffer().read(cx).is_singleton() { - let buffer_search_bar = self - .pane - .read(cx) - .toolbar() - .read(cx) - .item_of_type::(); - let search_bar_shown = buffer_search_bar - .as_ref() - .map(|bar| !bar.read(cx).is_dismissed()) - .unwrap_or(false); - + let search_bar_shown = !self.buffer_search_bar.read(cx).is_dismissed(); let search_action = buffer_search::Deploy { focus: true }; bar = bar.with_child(render_quick_action_bar_button( @@ -82,17 +72,13 @@ impl View for QuickActionBar { ), cx, move |this, cx| { - if search_bar_shown { - if let Some(buffer_search_bar) = buffer_search_bar.as_ref() { - buffer_search_bar.update(cx, |buffer_search_bar, cx| { - buffer_search_bar.dismiss(&buffer_search::Dismiss, cx); - }); + this.buffer_search_bar.update(cx, |buffer_search_bar, cx| { + if search_bar_shown { + buffer_search_bar.dismiss(&buffer_search::Dismiss, cx); + } else { + buffer_search_bar.deploy(&search_action, cx); } - } else { - this.pane.update(cx, |pane, cx| { - BufferSearchBar::deploy(pane, &search_action, cx); - }); - } + }); }, )); } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index fc3c78afe2..d85d311b8f 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -36,7 +36,7 @@ pub enum Event { } pub fn init(cx: &mut AppContext) { - cx.add_action(BufferSearchBar::deploy); + cx.add_action(BufferSearchBar::deploy_bar); cx.add_action(BufferSearchBar::dismiss); cx.add_action(BufferSearchBar::focus_editor); cx.add_action(BufferSearchBar::select_next_match); @@ -327,6 +327,19 @@ impl BufferSearchBar { cx.notify(); } + pub fn deploy(&mut self, deploy: &Deploy, cx: &mut ViewContext) -> bool { + if self.show(cx) { + self.search_suggested(cx); + if deploy.focus { + self.select_query(cx); + cx.focus_self(); + } + return true; + } + + false + } + pub fn show(&mut self, cx: &mut ViewContext) -> bool { if self.active_searchable_item.is_none() { return false; @@ -553,21 +566,15 @@ impl BufferSearchBar { .into_any() } - pub fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { + fn deploy_bar(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { let mut propagate_action = true; if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { search_bar.update(cx, |search_bar, cx| { - if search_bar.show(cx) { - search_bar.search_suggested(cx); - if action.focus { - search_bar.select_query(cx); - cx.focus_self(); - } + if search_bar.deploy(action, cx) { propagate_action = false; } }); } - if propagate_action { cx.propagate_action(); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 8d851909b3..de05c259c8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -257,16 +257,16 @@ pub fn initialize_workspace( let workspace_handle = cx.handle(); cx.subscribe(&workspace_handle, { move |workspace, _, event, cx| { - if let workspace::Event::PaneAdded(pane_handle) = event { - pane_handle.update(cx, |pane, cx| { + if let workspace::Event::PaneAdded(pane) = event { + pane.update(cx, |pane, cx| { pane.toolbar().update(cx, |toolbar, cx| { let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(workspace)); toolbar.add_item(breadcrumbs, cx); - let quick_action_bar = - cx.add_view(|_| QuickActionBar::new(pane_handle.clone())); - toolbar.add_item(quick_action_bar, cx); let buffer_search_bar = cx.add_view(BufferSearchBar::new); - toolbar.add_item(buffer_search_bar, cx); + toolbar.add_item(buffer_search_bar.clone(), cx); + let quick_action_bar = + cx.add_view(|_| QuickActionBar::new(buffer_search_bar)); + toolbar.add_item(quick_action_bar, cx); let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); toolbar.add_item(project_search_bar, cx); let submit_feedback_button = From f9131f657efa31b764eb87d18b7fa3004226548f Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 11 Aug 2023 17:51:37 +0300 Subject: [PATCH 116/128] Use InlayHint instead of Inlay where appropriate --- crates/collab/src/tests/integration_tests.rs | 4 +- crates/editor/src/editor.rs | 40 ++++++++++--------- crates/editor/src/inlay_hint_cache.rs | 10 ++--- crates/editor/src/scroll.rs | 6 +-- crates/project/src/project.rs | 8 ++-- .../quick_action_bar/src/quick_action_bar.rs | 27 +++++++------ 6 files changed, 50 insertions(+), 45 deletions(-) diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 2224ecb838..a03e2ff16f 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -7867,7 +7867,7 @@ async fn test_mutual_editor_inlay_hint_cache_update( .insert_tree( "/a", json!({ - "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out", + "main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out", "other.rs": "// Test file", }), ) @@ -8177,7 +8177,7 @@ async fn test_inlay_hint_refresh_is_forwarded( .insert_tree( "/a", json!({ - "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out", + "main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out", "other.rs": "// Test file", }), ) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ef02cee3d0..904e77c9f0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -302,7 +302,7 @@ actions!( Hover, Format, ToggleSoftWrap, - ToggleInlays, + ToggleInlayHints, RevealInFinder, CopyPath, CopyRelativePath, @@ -447,7 +447,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(Editor::toggle_code_actions); cx.add_action(Editor::open_excerpts); cx.add_action(Editor::toggle_soft_wrap); - cx.add_action(Editor::toggle_inlays); + cx.add_action(Editor::toggle_inlay_hints); cx.add_action(Editor::reveal_in_finder); cx.add_action(Editor::copy_path); cx.add_action(Editor::copy_relative_path); @@ -1239,7 +1239,7 @@ enum GotoDefinitionKind { } #[derive(Debug, Clone)] -enum InlayRefreshReason { +enum InlayHintRefreshReason { Toggle(bool), SettingsChange(InlayHintSettings), NewLinesShown, @@ -1357,8 +1357,8 @@ impl Editor { })); } project_subscriptions.push(cx.subscribe(project, |editor, _, event, cx| { - if let project::Event::RefreshInlays = event { - editor.refresh_inlays(InlayRefreshReason::RefreshRequested, cx); + if let project::Event::RefreshInlayHints = event { + editor.refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx); }; })); } @@ -2672,24 +2672,24 @@ impl Editor { } } - pub fn toggle_inlays(&mut self, _: &ToggleInlays, cx: &mut ViewContext) { - self.refresh_inlays( - InlayRefreshReason::Toggle(!self.inlay_hint_cache.enabled), + pub fn toggle_inlay_hints(&mut self, _: &ToggleInlayHints, cx: &mut ViewContext) { + self.refresh_inlay_hints( + InlayHintRefreshReason::Toggle(!self.inlay_hint_cache.enabled), cx, ); } - pub fn inlays_enabled(&self) -> bool { + pub fn inlay_hints_enabled(&self) -> bool { self.inlay_hint_cache.enabled } - fn refresh_inlays(&mut self, reason: InlayRefreshReason, cx: &mut ViewContext) { + fn refresh_inlay_hints(&mut self, reason: InlayHintRefreshReason, cx: &mut ViewContext) { if self.project.is_none() || self.mode != EditorMode::Full { return; } let (invalidate_cache, required_languages) = match reason { - InlayRefreshReason::Toggle(enabled) => { + InlayHintRefreshReason::Toggle(enabled) => { self.inlay_hint_cache.enabled = enabled; if enabled { (InvalidationStrategy::RefreshRequested, None) @@ -2706,7 +2706,7 @@ impl Editor { return; } } - InlayRefreshReason::SettingsChange(new_settings) => { + InlayHintRefreshReason::SettingsChange(new_settings) => { match self.inlay_hint_cache.update_settings( &self.buffer, new_settings, @@ -2724,11 +2724,13 @@ impl Editor { ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None), } } - InlayRefreshReason::NewLinesShown => (InvalidationStrategy::None, None), - InlayRefreshReason::BufferEdited(buffer_languages) => { + InlayHintRefreshReason::NewLinesShown => (InvalidationStrategy::None, None), + InlayHintRefreshReason::BufferEdited(buffer_languages) => { (InvalidationStrategy::BufferEdited, Some(buffer_languages)) } - InlayRefreshReason::RefreshRequested => (InvalidationStrategy::RefreshRequested, None), + InlayHintRefreshReason::RefreshRequested => { + (InvalidationStrategy::RefreshRequested, None) + } }; if let Some(InlaySplice { @@ -7728,8 +7730,8 @@ impl Editor { .cloned() .collect::>(); if !languages_affected.is_empty() { - self.refresh_inlays( - InlayRefreshReason::BufferEdited(languages_affected), + self.refresh_inlay_hints( + InlayHintRefreshReason::BufferEdited(languages_affected), cx, ); } @@ -7767,8 +7769,8 @@ impl Editor { fn settings_changed(&mut self, cx: &mut ViewContext) { self.refresh_copilot_suggestions(true, cx); - self.refresh_inlays( - InlayRefreshReason::SettingsChange(inlay_hint_settings( + self.refresh_inlay_hints( + InlayHintRefreshReason::SettingsChange(inlay_hint_settings( self.selections.newest_anchor().head(), &self.buffer.read(cx).snapshot(cx), cx, diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 4c998c3afa..b5ccdb4f2d 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -2684,7 +2684,7 @@ all hints should be invalidated and requeried for all of its visible excerpts" } #[gpui::test] - async fn test_toggle_inlays(cx: &mut gpui::TestAppContext) { + async fn test_toggle_inlay_hints(cx: &mut gpui::TestAppContext) { init_test(cx, |settings| { settings.defaults.inlay_hints = Some(InlayHintSettings { enabled: false, @@ -2697,7 +2697,7 @@ all hints should be invalidated and requeried for all of its visible excerpts" let (file_with_hints, editor, fake_server) = prepare_test_objects(cx).await; editor.update(cx, |editor, cx| { - editor.toggle_inlays(&crate::ToggleInlays, cx) + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) }); cx.foreground().start_waiting(); let lsp_request_count = Arc::new(AtomicU32::new(0)); @@ -2743,7 +2743,7 @@ all hints should be invalidated and requeried for all of its visible excerpts" }); editor.update(cx, |editor, cx| { - editor.toggle_inlays(&crate::ToggleInlays, cx) + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) }); cx.foreground().run_until_parked(); editor.update(cx, |editor, cx| { @@ -2776,7 +2776,7 @@ all hints should be invalidated and requeried for all of its visible excerpts" }); editor.update(cx, |editor, cx| { - editor.toggle_inlays(&crate::ToggleInlays, cx) + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) }); cx.foreground().run_until_parked(); editor.update(cx, |editor, cx| { @@ -2789,7 +2789,7 @@ all hints should be invalidated and requeried for all of its visible excerpts" }); editor.update(cx, |editor, cx| { - editor.toggle_inlays(&crate::ToggleInlays, cx) + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) }); cx.foreground().run_until_parked(); editor.update(cx, |editor, cx| { diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 1f3adaf477..f5edb00d58 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -19,7 +19,7 @@ use crate::{ display_map::{DisplaySnapshot, ToDisplayPoint}, hover_popover::hide_hover, persistence::DB, - Anchor, DisplayPoint, Editor, EditorMode, Event, InlayRefreshReason, MultiBufferSnapshot, + Anchor, DisplayPoint, Editor, EditorMode, Event, InlayHintRefreshReason, MultiBufferSnapshot, ToPoint, }; @@ -301,7 +301,7 @@ impl Editor { cx.spawn(|editor, mut cx| async move { editor .update(&mut cx, |editor, cx| { - editor.refresh_inlays(InlayRefreshReason::NewLinesShown, cx) + editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx) }) .ok() }) @@ -333,7 +333,7 @@ impl Editor { cx, ); - self.refresh_inlays(InlayRefreshReason::NewLinesShown, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } pub fn scroll_position(&self, cx: &mut ViewContext) -> Vector2F { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 1aa2a2dd40..933f259700 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -282,7 +282,7 @@ pub enum Event { new_peer_id: proto::PeerId, }, CollaboratorLeft(proto::PeerId), - RefreshInlays, + RefreshInlayHints, } pub enum LanguageServerState { @@ -2872,7 +2872,7 @@ impl Project { .upgrade(&cx) .ok_or_else(|| anyhow!("project dropped"))?; this.update(&mut cx, |project, cx| { - cx.emit(Event::RefreshInlays); + cx.emit(Event::RefreshInlayHints); project.remote_id().map(|project_id| { project.client.send(proto::RefreshInlayHints { project_id }) }) @@ -3436,7 +3436,7 @@ impl Project { cx: &mut ModelContext, ) { if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) { - cx.emit(Event::RefreshInlays); + cx.emit(Event::RefreshInlayHints); status.pending_work.remove(&token); cx.notify(); } @@ -6810,7 +6810,7 @@ impl Project { mut cx: AsyncAppContext, ) -> Result { this.update(&mut cx, |_, cx| { - cx.emit(Event::RefreshInlays); + cx.emit(Event::RefreshInlayHints); }); Ok(proto::Ack {}) } diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index b506f8dc17..a2bddff313 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -11,7 +11,7 @@ use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView}; pub struct QuickActionBar { buffer_search_bar: ViewHandle, active_item: Option>, - _inlays_enabled_subscription: Option, + _inlay_hints_enabled_subscription: Option, } impl QuickActionBar { @@ -19,7 +19,7 @@ impl QuickActionBar { Self { buffer_search_bar, active_item: None, - _inlays_enabled_subscription: None, + _inlay_hints_enabled_subscription: None, } } @@ -42,17 +42,20 @@ impl View for QuickActionBar { fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { let Some(editor) = self.active_editor() else { return Empty::new().into_any(); }; - let inlays_enabled = editor.read(cx).inlays_enabled(); + let inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); let mut bar = Flex::row().with_child(render_quick_action_bar_button( 0, "icons/hamburger_15.svg", - inlays_enabled, - ("Inlays".to_string(), Some(Box::new(editor::ToggleInlays))), + inlay_hints_enabled, + ( + "Inlay hints".to_string(), + Some(Box::new(editor::ToggleInlayHints)), + ), cx, |this, cx| { if let Some(editor) = this.active_editor() { editor.update(cx, |editor, cx| { - editor.toggle_inlays(&editor::ToggleInlays, cx); + editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx); }); } }, @@ -135,15 +138,15 @@ impl ToolbarItemView for QuickActionBar { match active_pane_item { Some(active_item) => { self.active_item = Some(active_item.boxed_clone()); - self._inlays_enabled_subscription.take(); + self._inlay_hints_enabled_subscription.take(); if let Some(editor) = active_item.downcast::() { - let mut inlays_enabled = editor.read(cx).inlays_enabled(); - self._inlays_enabled_subscription = + let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); + self._inlay_hints_enabled_subscription = Some(cx.observe(&editor, move |_, editor, cx| { - let new_inlays_enabled = editor.read(cx).inlays_enabled(); - if inlays_enabled != new_inlays_enabled { - inlays_enabled = new_inlays_enabled; + let new_inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); + if inlay_hints_enabled != new_inlay_hints_enabled { + inlay_hints_enabled = new_inlay_hints_enabled; cx.notify(); } })); From 3ed50708ac09274b5074a86a110e0ce7ac0162b7 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 11:25:11 -0400 Subject: [PATCH 117/128] Add inlay_hint icon, update search icon, update tooltips --- assets/icons/inlay_hint.svg | 6 ++++++ crates/quick_action_bar/src/quick_action_bar.rs | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 assets/icons/inlay_hint.svg diff --git a/assets/icons/inlay_hint.svg b/assets/icons/inlay_hint.svg new file mode 100644 index 0000000000..571e2e4784 --- /dev/null +++ b/assets/icons/inlay_hint.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index a2bddff313..ab3ccacdb7 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -45,10 +45,10 @@ impl View for QuickActionBar { let inlay_hints_enabled = editor.read(cx).inlay_hints_enabled(); let mut bar = Flex::row().with_child(render_quick_action_bar_button( 0, - "icons/hamburger_15.svg", + "icons/inlay_hint.svg", inlay_hints_enabled, ( - "Inlay hints".to_string(), + "Toggle Inlay Hints".to_string(), Some(Box::new(editor::ToggleInlayHints)), ), cx, @@ -67,10 +67,10 @@ impl View for QuickActionBar { bar = bar.with_child(render_quick_action_bar_button( 1, - "icons/magnifying_glass_12.svg", + "icons/magnifying_glass.svg", search_bar_shown, ( - "Buffer search".to_string(), + "Buffer Search".to_string(), Some(Box::new(search_action.clone())), ), cx, From 7fcf9022b45960e1407cd81bc80cb9acd2aaebda Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 16 Aug 2023 19:25:51 +0300 Subject: [PATCH 118/128] Fix rebase issues --- crates/quick_action_bar/src/quick_action_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs index ab3ccacdb7..3055399c13 100644 --- a/crates/quick_action_bar/src/quick_action_bar.rs +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -105,7 +105,7 @@ fn render_quick_action_bar_button< let theme = theme::current(cx); let (tooltip_text, action) = tooltip; - MouseEventHandler::::new(index, cx, |mouse_state, _| { + MouseEventHandler::new::(index, cx, |mouse_state, _| { let style = theme .workspace .toolbar From 5bb6a14d420071c25d1eaa05d79f460cfea02e27 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 14:20:41 -0400 Subject: [PATCH 119/128] Update inlay_hint icon --- assets/icons/inlay_hint.svg | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/assets/icons/inlay_hint.svg b/assets/icons/inlay_hint.svg index 571e2e4784..c8e6bb2d36 100644 --- a/assets/icons/inlay_hint.svg +++ b/assets/icons/inlay_hint.svg @@ -1,6 +1,5 @@ - - - - + + + From ef86c08174116d6b7a10b79ce15dcbb2715741b8 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 14:24:36 -0400 Subject: [PATCH 120/128] Use the `ghost` variant for a flat button style in the toolbar --- styles/src/style_tree/workspace.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/styles/src/style_tree/workspace.ts b/styles/src/style_tree/workspace.ts index 578dd23c6e..d4eaeb99da 100644 --- a/styles/src/style_tree/workspace.ts +++ b/styles/src/style_tree/workspace.ts @@ -152,6 +152,7 @@ export default function workspace(): any { }), toggleable_tool: toggleable_icon_button(theme, { margin: { left: 8 }, + variant: "ghost", active_color: "accent", }), padding: { left: 8, right: 8, top: 4, bottom: 4 }, From 7334bdccbff6065666c71dde8e7dee53497e939c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 16 Aug 2023 23:37:55 +0300 Subject: [PATCH 121/128] Better multibuffer tests --- crates/editor/src/inlay_hint_cache.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index b5ccdb4f2d..70cccf21da 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -2001,7 +2001,7 @@ mod tests { }); } - #[gpui::test] + #[gpui::test(iterations = 10)] async fn test_multiple_excerpts_large_multibuffer( deterministic: Arc, cx: &mut gpui::TestAppContext, @@ -2335,10 +2335,12 @@ mod tests { all hints should be invalidated and requeried for all of its visible excerpts" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - assert_eq!( - editor.inlay_hint_cache().version, - last_scroll_update_version + expected_layers.len(), - "Due to every excerpt having one hint, cache should update per new excerpt received" + + let current_cache_version = editor.inlay_hint_cache().version; + let minimum_expected_version = last_scroll_update_version + expected_layers.len(); + assert!( + current_cache_version == minimum_expected_version || current_cache_version == minimum_expected_version + 1, + "Due to every excerpt having one hint, cache should update per new excerpt received + 1 potential sporadic update" ); }); } From a5a212e1dadb6d84e0b29154d9bcca53ad3053af Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 16 Aug 2023 14:25:40 -0700 Subject: [PATCH 122/128] Use our fork of alacritty to avoid winit dependency --- Cargo.lock | 248 +------------------------------------ crates/terminal/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 244 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b54bdda02..474dc7fd8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,18 +126,17 @@ dependencies = [ [[package]] name = "alacritty_config" version = "0.1.2-dev" -source = "git+https://github.com/alacritty/alacritty?rev=7b9f32300ee0a249c0872302c97635b460e45ba5#7b9f32300ee0a249c0872302c97635b460e45ba5" +source = "git+https://github.com/zed-industries/alacritty?rev=f6d001ba8080ebfab6822106a436c64b677a44d5#f6d001ba8080ebfab6822106a436c64b677a44d5" dependencies = [ "log", "serde", "toml 0.7.6", - "winit", ] [[package]] name = "alacritty_config_derive" version = "0.2.2-dev" -source = "git+https://github.com/alacritty/alacritty?rev=7b9f32300ee0a249c0872302c97635b460e45ba5#7b9f32300ee0a249c0872302c97635b460e45ba5" +source = "git+https://github.com/zed-industries/alacritty?rev=f6d001ba8080ebfab6822106a436c64b677a44d5#f6d001ba8080ebfab6822106a436c64b677a44d5" dependencies = [ "proc-macro2", "quote", @@ -147,7 +146,7 @@ dependencies = [ [[package]] name = "alacritty_terminal" version = "0.20.0-dev" -source = "git+https://github.com/alacritty/alacritty?rev=7b9f32300ee0a249c0872302c97635b460e45ba5#7b9f32300ee0a249c0872302c97635b460e45ba5" +source = "git+https://github.com/zed-industries/alacritty?rev=f6d001ba8080ebfab6822106a436c64b677a44d5#f6d001ba8080ebfab6822106a436c64b677a44d5" dependencies = [ "alacritty_config", "alacritty_config_derive", @@ -213,30 +212,6 @@ version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8ad6edb4840b78c5c3d88de606b22252d552b55f3a4699fbb10fc070ec3049" -[[package]] -name = "android-activity" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64529721f27c2314ced0890ce45e469574a73e5e6fdd6e9da1860eb29285f5e0" -dependencies = [ - "android-properties", - "bitflags 1.3.2", - "cc", - "jni-sys", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum 0.6.1", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -926,25 +901,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-sys" -version = "0.1.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa55741ee90902547802152aaf3f8e5248aab7e21468089560d4c8840561146" -dependencies = [ - "objc-sys", -] - -[[package]] -name = "block2" -version = "0.2.0-alpha.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd9e63c1744f755c2f60332b88de39d341e5e86239014ad839bd71c106dec42" -dependencies = [ - "block-sys", - "objc2-encode", -] - [[package]] name = "blocking" version = "1.3.1" @@ -1126,20 +1082,6 @@ dependencies = [ "util", ] -[[package]] -name = "calloop" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e0d00eb1ea24371a97d2da6201c6747a633dc6dc1988ef503403b4c59504a8" -dependencies = [ - "bitflags 1.3.2", - "log", - "nix 0.25.1", - "slotmap", - "thiserror", - "vec_map", -] - [[package]] name = "cap-fs-ext" version = "0.24.4" @@ -1248,12 +1190,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "chrono" version = "0.4.26" @@ -2073,15 +2009,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "cursor-icon" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740bb192a8e2d1350119916954f4409ee7f62f149b536911eeb78ba5a20526bf" -dependencies = [ - "serde", -] - [[package]] name = "dashmap" version = "5.5.0" @@ -2288,12 +2215,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dlib" version = "0.5.2" @@ -4533,7 +4454,7 @@ dependencies = [ "bitflags 1.3.2", "jni-sys", "ndk-sys", - "num_enum 0.5.11", + "num_enum", "raw-window-handle", "thiserror", ] @@ -4575,19 +4496,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if 1.0.0", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.26.2" @@ -4753,16 +4661,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" dependencies = [ - "num_enum_derive 0.5.11", -] - -[[package]] -name = "num_enum" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1" -dependencies = [ - "num_enum_derive 0.6.1", + "num_enum_derive", ] [[package]] @@ -4777,18 +4676,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "num_enum_derive" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 2.0.28", -] - [[package]] name = "nvim-rs" version = "0.5.0" @@ -4814,32 +4701,6 @@ dependencies = [ "objc_exception", ] -[[package]] -name = "objc-sys" -version = "0.2.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b9834c1e95694a05a828b59f55fa2afec6288359cda67146126b3f90a55d7" - -[[package]] -name = "objc2" -version = "0.3.0-beta.3.patch-leaks.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e01640f9f2cb1220bbe80325e179e532cb3379ebcd1bf2279d703c19fe3a468" -dependencies = [ - "block2", - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "2.0.0-pre.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abfcac41015b00a120608fdaa6938c44cb983fee294351cc4bac7638b4e50512" -dependencies = [ - "objc-sys", -] - [[package]] name = "objc_exception" version = "0.1.2" @@ -4958,15 +4819,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "orbclient" -version = "0.3.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221d488cd70617f1bd599ed8ceb659df2147d9393717954d82a0f5e8032a6ab1" -dependencies = [ - "redox_syscall 0.3.5", -] - [[package]] name = "ordered-float" version = "2.10.0" @@ -7095,15 +6947,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" -[[package]] -name = "slotmap" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e08e261d0e8f5c43123b7adf3e4ca1690d655377ac93a03b2c9d3e98de1342" -dependencies = [ - "version_check", -] - [[package]] name = "sluice" version = "0.5.5" @@ -7148,15 +6991,6 @@ dependencies = [ "pin-project-lite 0.1.12", ] -[[package]] -name = "smol_str" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74212e6bbe9a4352329b2f68ba3130c15a3f26fe88ff22dbdc6cdd58fa85e99c" -dependencies = [ - "serde", -] - [[package]] name = "snippet" version = "0.1.0" @@ -8843,12 +8677,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "vec_map" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" - [[package]] name = "version_check" version = "0.9.4" @@ -9313,17 +9141,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19353897b48e2c4d849a2d73cb0aeb16dc2be4e00c565abfc11eb65a806e47de" -dependencies = [ - "js-sys", - "once_cell", - "wasm-bindgen", -] - [[package]] name = "webpki" version = "0.21.4" @@ -9639,42 +9456,6 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" -[[package]] -name = "winit" -version = "0.29.0-beta.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f1afaf8490cc3f1309520ebb53a4cd3fc3642c7df8064a4b074bb9867998d44" -dependencies = [ - "android-activity", - "atomic-waker", - "bitflags 2.3.3", - "calloop", - "cfg_aliases", - "core-foundation", - "core-graphics", - "cursor-icon", - "dispatch", - "js-sys", - "libc", - "log", - "ndk", - "ndk-sys", - "objc2", - "once_cell", - "orbclient", - "raw-window-handle", - "redox_syscall 0.3.5", - "serde", - "smol_str", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "web-time", - "windows-sys", - "xkbcommon-dl", -] - [[package]] name = "winnow" version = "0.5.2" @@ -9792,25 +9573,6 @@ dependencies = [ "libc", ] -[[package]] -name = "xkbcommon-dl" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6924668544c48c0133152e7eec86d644a056ca3d09275eb8d5cdb9855f9d8699" -dependencies = [ - "bitflags 2.3.3", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054a8e68b76250b253f671d1268cb7f1ae089ec35e195b2efb2a4e9a836d0621" - [[package]] name = "xmlparser" version = "0.13.5" diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index fbcf0ec4b9..18c0f8be3c 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -16,7 +16,7 @@ db = { path = "../db" } theme = { path = "../theme" } util = { path = "../util" } -alacritty_terminal = { git = "https://github.com/alacritty/alacritty", rev = "7b9f32300ee0a249c0872302c97635b460e45ba5" } +alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "f6d001ba8080ebfab6822106a436c64b677a44d5" } procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false } smallvec.workspace = true smol.workspace = true From 3074455386126085ecd8f68d9ecc8dddfece51b0 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 16 Aug 2023 16:56:00 -0700 Subject: [PATCH 123/128] WIP --- crates/collab/src/tests/channel_tests.rs | 13 +++++++++++++ styles/src/style_tree/collab_modals.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index d778b6a472..a250f59a21 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -770,6 +770,19 @@ async fn test_call_from_channel( }); } +#[gpui::test] +async fn test_lost_channel_creation( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + // Invite a member + // Create a new sub channel + // Member accepts invite + // Make sure that member can see new channel + todo!(); +} + #[derive(Debug, PartialEq)] struct ExpectedChannel { depth: usize, diff --git a/styles/src/style_tree/collab_modals.ts b/styles/src/style_tree/collab_modals.ts index 4bdeb45f9c..0f50e01a39 100644 --- a/styles/src/style_tree/collab_modals.ts +++ b/styles/src/style_tree/collab_modals.ts @@ -76,7 +76,8 @@ export default function channel_modal(): any { }, }, - max_height: 400, + // FIXME: due to a bug in the picker's size calculation, this must be 600 + max_height: 600, max_width: 540, title: { ...text(theme.middle, "sans", "on", { size: "lg" }), From 2f1614705569567b3e18331c4de18f8fe68df4af Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 16 Aug 2023 19:47:54 -0700 Subject: [PATCH 124/128] Fix dock resizing --- crates/ai/src/assistant.rs | 6 +- crates/collab_ui/src/collab_panel.rs | 4 +- crates/gpui/src/app.rs | 8 ++ crates/gpui/src/elements.rs | 17 ++- crates/gpui/src/elements/component.rs | 5 +- crates/gpui/src/elements/resizable.rs | 147 +++++++++++++++++---- crates/project_panel/src/project_panel.rs | 4 +- crates/terminal_view/src/terminal_panel.rs | 6 +- crates/workspace/src/dock.rs | 18 +-- crates/workspace/src/workspace.rs | 7 +- 10 files changed, 170 insertions(+), 52 deletions(-) diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index e0fe41aebe..70473cbc7f 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -726,10 +726,10 @@ impl Panel for AssistantPanel { } } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { match self.position(cx) { - DockPosition::Left | DockPosition::Right => self.width = Some(size), - DockPosition::Bottom => self.height = Some(size), + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => self.height = size, } cx.notify(); } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 4f0a61bf6a..b31ea0fbf2 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2391,8 +2391,8 @@ impl Panel for CollabPanel { .unwrap_or_else(|| settings::get::(cx).default_width) } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { - self.width = Some(size); + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; self.serialize(cx); cx.notify(); } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 8e6d43a45d..b08d9501f6 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -577,6 +577,14 @@ impl AppContext { } } + pub fn optional_global(&self) -> Option<&T> { + if let Some(global) = self.globals.get(&TypeId::of::()) { + Some(global.downcast_ref().unwrap()) + } else { + None + } + } + pub fn upgrade(&self) -> App { App(self.weak_self.as_ref().unwrap().upgrade().unwrap()) } diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index b8af978658..f1be9b34ae 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -186,16 +186,27 @@ pub trait Element: 'static { Tooltip::new::(id, text, action, style, self.into_any(), cx) } - fn resizable( + /// Uses the the given element to calculate resizes for the given tag + fn provide_resize_bounds(self) -> BoundsProvider + where + Self: 'static + Sized, + { + BoundsProvider::<_, Tag>::new(self.into_any()) + } + + /// Calls the given closure with the new size of the element whenever the + /// handle is dragged. This will be calculated in relation to the bounds + /// provided by the given tag + fn resizable( self, side: HandleSide, size: f32, - on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext), + on_resize: impl 'static + FnMut(&mut V, Option, &mut ViewContext), ) -> Resizable where Self: 'static + Sized, { - Resizable::new(self.into_any(), side, size, on_resize) + Resizable::new::(self.into_any(), side, size, on_resize) } fn mouse(self, region_id: usize) -> MouseEventHandler diff --git a/crates/gpui/src/elements/component.rs b/crates/gpui/src/elements/component.rs index 1c4359e2c3..2f9cc6cce6 100644 --- a/crates/gpui/src/elements/component.rs +++ b/crates/gpui/src/elements/component.rs @@ -82,6 +82,9 @@ impl + 'static> Element for ComponentAdapter { view: &V, cx: &ViewContext, ) -> serde_json::Value { - element.debug(view, cx) + serde_json::json!({ + "type": "ComponentAdapter", + "child": element.debug(view, cx), + }) } } diff --git a/crates/gpui/src/elements/resizable.rs b/crates/gpui/src/elements/resizable.rs index 0b1d94f8f8..37e40d6584 100644 --- a/crates/gpui/src/elements/resizable.rs +++ b/crates/gpui/src/elements/resizable.rs @@ -1,14 +1,14 @@ use std::{cell::RefCell, rc::Rc}; +use collections::HashMap; use pathfinder_geometry::vector::{vec2f, Vector2F}; use serde_json::json; use crate::{ geometry::rect::RectF, platform::{CursorStyle, MouseButton}, - scene::MouseDrag, - AnyElement, Axis, Element, LayoutContext, MouseRegion, PaintContext, SceneBuilder, - SizeConstraint, View, ViewContext, + AnyElement, AppContext, Axis, Element, LayoutContext, MouseRegion, PaintContext, SceneBuilder, + SizeConstraint, TypeTag, View, ViewContext, }; #[derive(Copy, Clone, Debug)] @@ -27,15 +27,6 @@ impl HandleSide { } } - /// 'before' is in reference to the standard english document ordering of left-to-right - /// then top-to-bottom - fn before_content(self) -> bool { - match self { - HandleSide::Left | HandleSide::Top => true, - HandleSide::Right | HandleSide::Bottom => false, - } - } - fn relevant_component(&self, vector: Vector2F) -> f32 { match self.axis() { Axis::Horizontal => vector.x(), @@ -43,14 +34,6 @@ impl HandleSide { } } - fn compute_delta(&self, e: MouseDrag) -> f32 { - if self.before_content() { - self.relevant_component(e.prev_mouse_position) - self.relevant_component(e.position) - } else { - self.relevant_component(e.position) - self.relevant_component(e.prev_mouse_position) - } - } - fn of_rect(&self, bounds: RectF, handle_size: f32) -> RectF { match self { HandleSide::Top => RectF::new(bounds.origin(), vec2f(bounds.width(), handle_size)), @@ -69,21 +52,29 @@ impl HandleSide { } } +fn get_bounds(tag: TypeTag, cx: &AppContext) -> Option<&(RectF, RectF)> +where +{ + cx.optional_global::() + .and_then(|map| map.0.get(&tag)) +} + pub struct Resizable { child: AnyElement, + tag: TypeTag, handle_side: HandleSide, handle_size: f32, - on_resize: Rc)>>, + on_resize: Rc, &mut ViewContext)>>, } const DEFAULT_HANDLE_SIZE: f32 = 4.0; impl Resizable { - pub fn new( + pub fn new( child: AnyElement, handle_side: HandleSide, size: f32, - on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext), + on_resize: impl 'static + FnMut(&mut V, Option, &mut ViewContext), ) -> Self { let child = match handle_side.axis() { Axis::Horizontal => child.constrained().with_max_width(size), @@ -94,6 +85,7 @@ impl Resizable { Self { child, handle_side, + tag: TypeTag::new::(), handle_size: DEFAULT_HANDLE_SIZE, on_resize: Rc::new(RefCell::new(on_resize)), } @@ -139,6 +131,14 @@ impl Element for Resizable { handle_region, ) .on_down(MouseButton::Left, |_, _: &mut V, _| {}) // This prevents the mouse down event from being propagated elsewhere + .on_click(MouseButton::Left, { + let on_resize = self.on_resize.clone(); + move |click, v, cx| { + if click.click_count == 2 { + on_resize.borrow_mut()(v, None, cx); + } + } + }) .on_drag(MouseButton::Left, { let bounds = bounds.clone(); let side = self.handle_side; @@ -146,16 +146,30 @@ impl Element for Resizable { let min_size = side.relevant_component(constraint.min); let max_size = side.relevant_component(constraint.max); let on_resize = self.on_resize.clone(); + let tag = self.tag; move |event, view: &mut V, cx| { if event.end { return; } - let new_size = min_size - .max(prev_size + side.compute_delta(event)) - .min(max_size) - .round(); + + let Some((bounds, _)) = get_bounds(tag, cx) else { + return; + }; + + let new_size_raw = match side { + // Handle on top side of element => Element is on bottom + HandleSide::Top => bounds.height() + bounds.origin_y() - event.position.y(), + // Handle on right side of element => Element is on left + HandleSide::Right => event.position.x() - bounds.lower_left().x(), + // Handle on left side of element => Element is on the right + HandleSide::Left => bounds.width() + bounds.origin_x() - event.position.x(), + // Handle on bottom side of element => Element is on the top + HandleSide::Bottom => event.position.y() - bounds.lower_left().y(), + }; + + let new_size = min_size.max(new_size_raw).min(max_size).round(); if new_size != prev_size { - on_resize.borrow_mut()(view, new_size, cx); + on_resize.borrow_mut()(view, Some(new_size), cx); } } }), @@ -201,3 +215,80 @@ impl Element for Resizable { }) } } + +#[derive(Debug, Default)] +struct ProviderMap(HashMap); + +pub struct BoundsProvider { + child: AnyElement, + phantom: std::marker::PhantomData

, +} + +impl BoundsProvider { + pub fn new(child: AnyElement) -> Self { + Self { + child, + phantom: std::marker::PhantomData, + } + } +} + +impl Element for BoundsProvider { + type LayoutState = (); + + type PaintState = (); + + fn layout( + &mut self, + constraint: crate::SizeConstraint, + view: &mut V, + cx: &mut crate::LayoutContext, + ) -> (pathfinder_geometry::vector::Vector2F, Self::LayoutState) { + (self.child.layout(constraint, view, cx), ()) + } + + fn paint( + &mut self, + scene: &mut crate::SceneBuilder, + bounds: pathfinder_geometry::rect::RectF, + visible_bounds: pathfinder_geometry::rect::RectF, + _: &mut Self::LayoutState, + view: &mut V, + cx: &mut crate::PaintContext, + ) -> Self::PaintState { + cx.update_default_global::(|map, _| { + map.0.insert(TypeTag::new::

(), (bounds, visible_bounds)); + }); + + self.child + .paint(scene, bounds.origin(), visible_bounds, view, cx) + } + + fn rect_for_text_range( + &self, + range_utf16: std::ops::Range, + _: pathfinder_geometry::rect::RectF, + _: pathfinder_geometry::rect::RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + view: &V, + cx: &crate::ViewContext, + ) -> Option { + self.child.rect_for_text_range(range_utf16, view, cx) + } + + fn debug( + &self, + _: pathfinder_geometry::rect::RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + view: &V, + cx: &crate::ViewContext, + ) -> serde_json::Value { + serde_json::json!({ + "type": "Provider", + "providing": format!("{:?}", TypeTag::new::

()), + "child": self.child.debug(view, cx), + }) + } +} diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 7a3e405e58..9fbbd3408f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1651,8 +1651,8 @@ impl workspace::dock::Panel for ProjectPanel { .unwrap_or_else(|| settings::get::(cx).default_width) } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { - self.width = Some(size); + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; self.serialize(cx); cx.notify(); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 7141cda172..472e748359 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -362,10 +362,10 @@ impl Panel for TerminalPanel { } } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { match self.position(cx) { - DockPosition::Left | DockPosition::Right => self.width = Some(size), - DockPosition::Bottom => self.height = Some(size), + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => self.height = size, } self.serialize(cx); cx.notify(); diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 55233c0836..3d40c8c420 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1,4 +1,4 @@ -use crate::{StatusItemView, Workspace}; +use crate::{StatusItemView, Workspace, WorkspaceBounds}; use context_menu::{ContextMenu, ContextMenuItem}; use gpui::{ elements::*, platform::CursorStyle, platform::MouseButton, Action, AnyViewHandle, AppContext, @@ -13,7 +13,7 @@ pub trait Panel: View { fn position_is_valid(&self, position: DockPosition) -> bool; 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 set_size(&mut self, size: Option, cx: &mut ViewContext); fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>; fn icon_tooltip(&self) -> (String, Option>); fn icon_label(&self, _: &WindowContext) -> Option { @@ -50,7 +50,7 @@ pub trait PanelHandle { fn set_zoomed(&self, zoomed: bool, cx: &mut WindowContext); 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 set_size(&self, size: Option, cx: &mut WindowContext); fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>; fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option>); fn icon_label(&self, cx: &WindowContext) -> Option; @@ -82,7 +82,7 @@ where self.read(cx).size(cx) } - fn set_size(&self, size: f32, cx: &mut WindowContext) { + fn set_size(&self, size: Option, cx: &mut WindowContext) { self.update(cx, |this, cx| this.set_size(size, cx)) } @@ -373,7 +373,7 @@ impl Dock { } } - pub fn resize_active_panel(&mut self, size: f32, cx: &mut ViewContext) { + pub fn resize_active_panel(&mut self, size: Option, cx: &mut ViewContext) { if let Some(entry) = self.panel_entries.get_mut(self.active_panel_index) { entry.panel.set_size(size, cx); cx.notify(); @@ -386,7 +386,7 @@ impl Dock { .into_any() .contained() .with_style(self.style(cx)) - .resizable( + .resizable::( self.position.to_resize_handle_side(), active_entry.panel.size(cx), |_, _, _| {}, @@ -423,7 +423,7 @@ impl View for Dock { ChildView::new(active_entry.panel.as_any(), cx) .contained() .with_style(style) - .resizable( + .resizable::( self.position.to_resize_handle_side(), active_entry.panel.size(cx), |dock: &mut Self, size, cx| dock.resize_active_panel(size, cx), @@ -700,8 +700,8 @@ pub mod test { self.size } - fn set_size(&mut self, size: f32, _: &mut ViewContext) { - self.size = size; + fn set_size(&mut self, size: Option, _: &mut ViewContext) { + self.size = size.unwrap_or(300.); } fn icon_path(&self, _: &WindowContext) -> Option<&'static str> { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 79f38f8e30..79b701e015 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -553,6 +553,8 @@ struct FollowerState { items_by_leader_view_id: HashMap>, } +enum WorkspaceBounds {} + impl Workspace { pub fn new( workspace_id: WorkspaceId, @@ -3776,6 +3778,7 @@ impl View for Workspace { })) .with_children(self.render_notifications(&theme.workspace, cx)), )) + .provide_resize_bounds::() .flex(1.0, true), ) .with_child(ChildView::new(&self.status_bar, cx)) @@ -4859,7 +4862,9 @@ mod tests { panel_1.size(cx) ); - left_dock.update(cx, |left_dock, cx| left_dock.resize_active_panel(1337., cx)); + left_dock.update(cx, |left_dock, cx| { + left_dock.resize_active_panel(Some(1337.), cx) + }); assert_eq!( workspace .right_dock() From 8b1322745d6d21b17bcb971411d85019f85e2331 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 16 Aug 2023 22:50:02 -0400 Subject: [PATCH 125/128] Fix collab indicators --- styles/src/component/indicator.ts | 4 ++-- styles/src/style_tree/collab_panel.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/styles/src/component/indicator.ts b/styles/src/component/indicator.ts index 3a078fb53f..81a3b40da7 100644 --- a/styles/src/component/indicator.ts +++ b/styles/src/component/indicator.ts @@ -1,9 +1,9 @@ -import { background } from "../style_tree/components" +import { foreground } 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), + background: foreground(layer, color), }) diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 2d8c050838..7f0fd5f423 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -8,7 +8,6 @@ import { import { interactive, toggleable } from "../element" import { useTheme } from "../theme" import collab_modals from "./collab_modals" -import { text_button } from "../component/text_button" import { icon_button, toggleable_icon_button } from "../component/icon_button" import { indicator } from "../component/indicator" From 05becc75d1919588e45b9ee5e3a1810415681c25 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 16 Aug 2023 19:51:41 -0700 Subject: [PATCH 126/128] Collapse offline section by default --- crates/collab_ui/src/collab_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b31ea0fbf2..0e7bd5f929 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -397,7 +397,7 @@ impl CollabPanel { project: workspace.project().clone(), subscriptions: Vec::default(), match_candidates: Vec::default(), - collapsed_sections: Vec::default(), + collapsed_sections: vec![Section::Offline], workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), context_menu_on_selected: true, From 5bc481112eccfe187103f1b504ff28b401cdb44f Mon Sep 17 00:00:00 2001 From: Mikayla Date: Wed, 16 Aug 2023 20:05:21 -0700 Subject: [PATCH 127/128] Add test for lost channel update --- crates/collab/src/tests/channel_tests.rs | 97 +++++++++++++++++++++++- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index a250f59a21..06cf3607c0 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -776,11 +776,100 @@ async fn test_lost_channel_creation( 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; + + 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 []).await; + // Invite a member - // Create a new sub channel - // Member accepts invite - // Make sure that member can see new channel - todo!(); + client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.invite_member(channel_id, client_b.user_id().unwrap(), false, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + // Sanity check + assert_channel_invitations( + client_b.channel_store(), + cx_b, + &[ExpectedChannel { + depth: 0, + id: channel_id, + name: "x".to_string(), + user_is_admin: false, + }], + ); + + let subchannel_id = client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.create_channel("subchannel", Some(channel_id), cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + // Make sure A sees their new channel + assert_channels( + client_a.channel_store(), + cx_a, + &[ + ExpectedChannel { + depth: 0, + id: channel_id, + name: "x".to_string(), + user_is_admin: true, + }, + ExpectedChannel { + depth: 1, + id: subchannel_id, + name: "subchannel".to_string(), + user_is_admin: true, + }, + ], + ); + + // Accept the invite + client_b + .channel_store() + .update(cx_b, |channel_store, _| { + channel_store.respond_to_channel_invite(channel_id, true) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + // B should now see the channel + assert_channels( + client_b.channel_store(), + cx_b, + &[ + ExpectedChannel { + depth: 0, + id: channel_id, + name: "x".to_string(), + user_is_admin: false, + }, + ExpectedChannel { + depth: 1, + id: subchannel_id, + name: "subchannel".to_string(), + user_is_admin: false, + }, + ], + ); } #[derive(Debug, PartialEq)] From 75679291a9a7353d7ddc444e460163c881806430 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Thu, 17 Aug 2023 00:55:11 -0700 Subject: [PATCH 128/128] Add fix for lost channel update bug --- crates/client/src/channel_store.rs | 2 ++ crates/collab/src/db.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs index e2c18a63a9..03d334a9de 100644 --- a/crates/client/src/channel_store.rs +++ b/crates/client/src/channel_store.rs @@ -441,10 +441,12 @@ impl ChannelStore { for channel in payload.channels { if let Some(existing_channel) = self.channels_by_id.get_mut(&channel.id) { + // FIXME: We may be missing a path for this existing channel in certain cases let existing_channel = Arc::make_mut(existing_channel); existing_channel.name = channel.name; continue; } + self.channels_by_id.insert( channel.id, Arc::new(Channel { diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 64349123af..b457c4c116 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -3650,7 +3650,11 @@ impl Database { 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())) + .filter( + channel_member::Column::ChannelId + .is_in(ancestor_ids.iter().copied()) + .and(channel_member::Column::Accepted.eq(true)), + ) .select_only() .column(channel_member::Column::UserId) .into_values::<_, QueryUserIds>()