diff --git a/Cargo.lock b/Cargo.lock index e6dbada745..b42e11d326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,6 +1214,7 @@ dependencies = [ "serde", "serde_derive", "settings", + "smallvec", "smol", "sum_tree", "tempfile", @@ -1495,6 +1496,7 @@ dependencies = [ "serde_json", "settings", "sha-1 0.9.8", + "smallvec", "sqlx", "text", "theme", @@ -1525,6 +1527,7 @@ dependencies = [ "collections", "context_menu", "db", + "drag_and_drop", "editor", "feature_flags", "feedback", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 2211f9563d..14b9d01b21 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -585,6 +585,14 @@ "space": "menu::Confirm" } }, + { + "context": "CollabPanel > Editor", + "bindings": { + "cmd-c": "collab_panel::StartLinkChannel", + "cmd-x": "collab_panel::StartMoveChannel", + "cmd-v": "collab_panel::MoveOrLinkToSelected" + } + }, { "context": "ChannelModal", "bindings": { diff --git a/crates/channel/Cargo.toml b/crates/channel/Cargo.toml index 00e9135bc1..16a1d418d5 100644 --- a/crates/channel/Cargo.toml +++ b/crates/channel/Cargo.toml @@ -28,6 +28,7 @@ anyhow.workspace = true futures.workspace = true image = "0.23" lazy_static.workspace = true +smallvec.workspace = true log.workspace = true parking_lot.workspace = true postage.workspace = true diff --git a/crates/channel/src/channel.rs b/crates/channel/src/channel.rs index 37f1c0ce44..724ff75d60 100644 --- a/crates/channel/src/channel.rs +++ b/crates/channel/src/channel.rs @@ -4,7 +4,9 @@ mod channel_store; pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent}; pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId}; -pub use channel_store::{Channel, ChannelEvent, ChannelId, ChannelMembership, ChannelStore}; +pub use channel_store::{ + Channel, ChannelData, ChannelEvent, ChannelId, ChannelMembership, ChannelPath, ChannelStore, +}; use client::Client; use std::sync::Arc; diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index e61e520b47..ee1929f720 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -1,20 +1,37 @@ +mod channel_index; + use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat}; use anyhow::{anyhow, Result}; use client::{Client, Subscription, User, UserId, UserStore}; -use collections::{hash_map, HashMap, HashSet}; +use collections::{ + hash_map::{self, DefaultHasher}, + HashMap, HashSet, +}; use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt}; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle}; -use rpc::{proto, TypedEnvelope}; -use std::{mem, sync::Arc, time::Duration}; +use rpc::{ + proto::{self, ChannelEdge, ChannelPermission}, + TypedEnvelope, +}; +use serde_derive::{Deserialize, Serialize}; +use std::{ + borrow::Cow, + hash::{Hash, Hasher}, + mem, + ops::Deref, + sync::Arc, + time::Duration, +}; use util::ResultExt; +use self::channel_index::ChannelIndex; + pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); pub type ChannelId = u64; pub struct ChannelStore { - channels_by_id: HashMap>, - channel_paths: Vec>, + channel_index: ChannelIndex, channel_invitations: Vec>, channel_participants: HashMap>>, channels_with_admin_privileges: HashSet, @@ -30,12 +47,17 @@ pub struct ChannelStore { _update_channels: Task<()>, } +pub type ChannelData = (Channel, ChannelPath); + #[derive(Clone, Debug, PartialEq)] pub struct Channel { pub id: ChannelId, pub name: String, } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] +pub struct ChannelPath(Arc<[ChannelId]>); + pub struct ChannelMembership { pub user: Arc, pub kind: proto::channel_member::Kind, @@ -82,9 +104,8 @@ impl ChannelStore { }); Self { - channels_by_id: HashMap::default(), channel_invitations: Vec::default(), - channel_paths: Vec::default(), + channel_index: ChannelIndex::default(), channel_participants: Default::default(), channels_with_admin_privileges: Default::default(), outgoing_invites: Default::default(), @@ -116,7 +137,7 @@ impl ChannelStore { } pub fn has_children(&self, channel_id: ChannelId) -> bool { - self.channel_paths.iter().any(|path| { + self.channel_index.iter().any(|path| { if let Some(ix) = path.iter().position(|id| *id == channel_id) { path.len() > ix + 1 } else { @@ -125,29 +146,43 @@ impl ChannelStore { }) } + /// Returns the number of unique channels in the store pub fn channel_count(&self) -> usize { - self.channel_paths.len() + self.channel_index.by_id().len() } + /// Returns the index of a channel ID in the list of unique channels pub fn index_of_channel(&self, channel_id: ChannelId) -> Option { - self.channel_paths - .iter() - .position(|path| path.ends_with(&[channel_id])) + self.channel_index + .by_id() + .keys() + .position(|id| *id == channel_id) } - pub fn channels(&self) -> impl '_ + Iterator)> { - self.channel_paths.iter().map(move |path| { + /// Returns an iterator over all unique channels + pub fn channels(&self) -> impl '_ + Iterator> { + self.channel_index.by_id().values() + } + + /// Iterate over all entries in the channel DAG + pub fn channel_dag_entries(&self) -> impl '_ + Iterator)> { + self.channel_index.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)?; + pub fn channel_dag_entry_at(&self, ix: usize) -> Option<(&Arc, &ChannelPath)> { + let path = self.channel_index.get(ix)?; let id = path.last().unwrap(); let channel = self.channel_for_id(*id).unwrap(); - Some((path.len() - 1, channel)) + + Some((channel, path)) + } + + pub fn channel_at(&self, ix: usize) -> Option<&Arc> { + self.channel_index.by_id().values().nth(ix) } pub fn channel_invitations(&self) -> &[Arc] { @@ -155,7 +190,7 @@ impl ChannelStore { } pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc> { - self.channels_by_id.get(&channel_id) + self.channel_index.by_id().get(&channel_id) } pub fn has_open_channel_buffer(&self, channel_id: ChannelId, cx: &AppContext) -> bool { @@ -268,7 +303,7 @@ impl ChannelStore { } pub fn is_user_admin(&self, channel_id: ChannelId) -> bool { - self.channel_paths.iter().any(|path| { + self.channel_index.iter().any(|path| { if let Some(ix) = path.iter().position(|id| *id == channel_id) { path[..=ix] .iter() @@ -294,18 +329,33 @@ impl ChannelStore { let client = self.client.clone(); let name = name.trim_start_matches("#").to_owned(); cx.spawn(|this, mut cx| async move { - let channel = client + let response = client .request(proto::CreateChannel { name, parent_id }) - .await? + .await?; + + let channel = response .channel .ok_or_else(|| anyhow!("missing channel in response"))?; - let channel_id = channel.id; + let parent_edge = if let Some(parent_id) = parent_id { + vec![ChannelEdge { + channel_id: channel.id, + parent_id, + }] + } else { + vec![] + }; + this.update(&mut cx, |this, cx| { let task = this.update_channels( proto::UpdateChannels { channels: vec![channel], + insert_edge: parent_edge, + channel_permissions: vec![ChannelPermission { + channel_id, + is_admin: true, + }], ..Default::default() }, cx, @@ -323,6 +373,59 @@ impl ChannelStore { }) } + pub fn link_channel( + &mut self, + channel_id: ChannelId, + to: ChannelId, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.spawn(|_, _| async move { + let _ = client + .request(proto::LinkChannel { channel_id, to }) + .await?; + + Ok(()) + }) + } + + pub fn unlink_channel( + &mut self, + channel_id: ChannelId, + from: ChannelId, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.spawn(|_, _| async move { + let _ = client + .request(proto::UnlinkChannel { channel_id, from }) + .await?; + + Ok(()) + }) + } + + pub fn move_channel( + &mut self, + channel_id: ChannelId, + from: ChannelId, + to: ChannelId, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.spawn(|_, _| async move { + let _ = client + .request(proto::MoveChannel { + channel_id, + from, + to, + }) + .await?; + + Ok(()) + }) + } + pub fn invite_member( &mut self, channel_id: ChannelId, @@ -502,7 +605,7 @@ impl ChannelStore { pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future> { let client = self.client.clone(); async move { - client.request(proto::RemoveChannel { channel_id }).await?; + client.request(proto::DeleteChannel { channel_id }).await?; Ok(()) } } @@ -639,11 +742,11 @@ impl ChannelStore { } fn handle_disconnect(&mut self, cx: &mut ModelContext) { - self.channels_by_id.clear(); + self.channel_index.clear(); self.channel_invitations.clear(); self.channel_participants.clear(); self.channels_with_admin_privileges.clear(); - self.channel_paths.clear(); + self.channel_index.clear(); self.outgoing_invites.clear(); cx.notify(); @@ -690,17 +793,20 @@ impl ChannelStore { } } - 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)); + let channels_changed = !payload.channels.is_empty() + || !payload.delete_channels.is_empty() + || !payload.insert_edge.is_empty() + || !payload.delete_edge.is_empty(); - for channel_id in &payload.remove_channels { + if channels_changed { + if !payload.delete_channels.is_empty() { + self.channel_index.delete_channels(&payload.delete_channels); + self.channel_participants + .retain(|channel_id, _| !payload.delete_channels.contains(channel_id)); + self.channels_with_admin_privileges + .retain(|channel_id| !payload.delete_channels.contains(channel_id)); + + for channel_id in &payload.delete_channels { let channel_id = *channel_id; if let Some(OpenedModelHandle::Open(buffer)) = self.opened_buffers.remove(&channel_id) @@ -712,44 +818,18 @@ impl ChannelStore { } } - for channel_proto in payload.channels { - if let Some(existing_channel) = self.channels_by_id.get_mut(&channel_proto.id) { - Arc::make_mut(existing_channel).name = channel_proto.name; - } else { - let channel = Arc::new(Channel { - id: channel_proto.id, - name: channel_proto.name, - }); - self.channels_by_id.insert(channel.id, channel.clone()); - - if let Some(parent_id) = channel_proto.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]); - } - } + let mut index = self.channel_index.bulk_insert(); + for channel in payload.channels { + index.insert(channel) } - 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 edge in payload.insert_edge { + index.insert_edge(edge.channel_id, edge.parent_id); + } + + for edge in payload.delete_edge { + index.delete_edge(edge.parent_id, edge.channel_id); + } } for permission in payload.channel_permissions { @@ -807,12 +887,51 @@ impl ChannelStore { anyhow::Ok(()) })) } +} - 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())) +impl Deref for ChannelPath { + type Target = [ChannelId]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ChannelPath { + pub fn new(path: Arc<[ChannelId]>) -> Self { + debug_assert!(path.len() >= 1); + Self(path) + } + + pub fn parent_id(&self) -> Option { + self.0.len().checked_sub(2).map(|i| self.0[i]) + } + + pub fn channel_id(&self) -> ChannelId { + self.0[self.0.len() - 1] + } + + pub fn unique_id(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.0.deref().hash(&mut hasher); + hasher.finish() + } +} + +impl From for Cow<'static, ChannelPath> { + fn from(value: ChannelPath) -> Self { + Cow::Owned(value) + } +} + +impl<'a> From<&'a ChannelPath> for Cow<'a, ChannelPath> { + fn from(value: &'a ChannelPath) -> Self { + Cow::Borrowed(value) + } +} + +impl Default for ChannelPath { + fn default() -> Self { + ChannelPath(Arc::from([])) } } diff --git a/crates/channel/src/channel_store/channel_index.rs b/crates/channel/src/channel_store/channel_index.rs new file mode 100644 index 0000000000..d0c49dc298 --- /dev/null +++ b/crates/channel/src/channel_store/channel_index.rs @@ -0,0 +1,162 @@ +use std::{ops::Deref, sync::Arc}; + +use crate::{Channel, ChannelId}; +use collections::BTreeMap; +use rpc::proto; + +use super::ChannelPath; + +#[derive(Default, Debug)] +pub struct ChannelIndex { + paths: Vec, + channels_by_id: BTreeMap>, +} + +impl ChannelIndex { + pub fn by_id(&self) -> &BTreeMap> { + &self.channels_by_id + } + + pub fn clear(&mut self) { + self.paths.clear(); + self.channels_by_id.clear(); + } + + /// Delete the given channels from this index. + pub fn delete_channels(&mut self, channels: &[ChannelId]) { + self.channels_by_id + .retain(|channel_id, _| !channels.contains(channel_id)); + self.paths.retain(|path| { + path.iter() + .all(|channel_id| self.channels_by_id.contains_key(channel_id)) + }); + } + + pub fn bulk_insert(&mut self) -> ChannelPathsInsertGuard { + ChannelPathsInsertGuard { + paths: &mut self.paths, + channels_by_id: &mut self.channels_by_id, + } + } +} + +impl Deref for ChannelIndex { + type Target = [ChannelPath]; + + fn deref(&self) -> &Self::Target { + &self.paths + } +} + +/// A guard for ensuring that the paths index maintains its sort and uniqueness +/// invariants after a series of insertions +#[derive(Debug)] +pub struct ChannelPathsInsertGuard<'a> { + paths: &'a mut Vec, + channels_by_id: &'a mut BTreeMap>, +} + +impl<'a> ChannelPathsInsertGuard<'a> { + /// Remove the given edge from this index. This will not remove the channel. + /// If this operation would result in a dangling edge, re-insert it. + pub fn delete_edge(&mut self, parent_id: ChannelId, channel_id: ChannelId) { + self.paths.retain(|path| { + !path + .windows(2) + .any(|window| window == [parent_id, channel_id]) + }); + + // Ensure that there is at least one channel path in the index + if !self + .paths + .iter() + .any(|path| path.iter().any(|id| id == &channel_id)) + { + self.insert_root(channel_id); + } + } + + pub fn insert(&mut self, channel_proto: proto::Channel) { + if let Some(existing_channel) = self.channels_by_id.get_mut(&channel_proto.id) { + Arc::make_mut(existing_channel).name = channel_proto.name; + } else { + self.channels_by_id.insert( + channel_proto.id, + Arc::new(Channel { + id: channel_proto.id, + name: channel_proto.name, + }), + ); + self.insert_root(channel_proto.id); + } + } + + pub fn insert_edge(&mut self, channel_id: ChannelId, parent_id: ChannelId) { + let mut parents = Vec::new(); + let mut descendants = Vec::new(); + let mut ixs_to_remove = Vec::new(); + + for (ix, path) in self.paths.iter().enumerate() { + if path + .windows(2) + .any(|window| window[0] == parent_id && window[1] == channel_id) + { + // We already have this edge in the index + return; + } + if path.ends_with(&[parent_id]) { + parents.push(path); + } else if let Some(position) = path.iter().position(|id| id == &channel_id) { + if position == 0 { + ixs_to_remove.push(ix); + } + descendants.push(path.split_at(position).1); + } + } + + let mut new_paths = Vec::new(); + for parent in parents.iter() { + if descendants.is_empty() { + let mut new_path = Vec::with_capacity(parent.len() + 1); + new_path.extend_from_slice(parent); + new_path.push(channel_id); + new_paths.push(ChannelPath::new(new_path.into())); + } else { + for descendant in descendants.iter() { + let mut new_path = Vec::with_capacity(parent.len() + descendant.len()); + new_path.extend_from_slice(parent); + new_path.extend_from_slice(descendant); + new_paths.push(ChannelPath::new(new_path.into())); + } + } + } + + for ix in ixs_to_remove.into_iter().rev() { + self.paths.swap_remove(ix); + } + self.paths.extend(new_paths) + } + + fn insert_root(&mut self, channel_id: ChannelId) { + self.paths.push(ChannelPath::new(Arc::from([channel_id]))); + } +} + +impl<'a> Drop for ChannelPathsInsertGuard<'a> { + fn drop(&mut self) { + self.paths.sort_by(|a, b| { + let a = channel_path_sorting_key(a, &self.channels_by_id); + let b = channel_path_sorting_key(b, &self.channels_by_id); + a.cmp(b) + }); + self.paths.dedup(); + } +} + +fn channel_path_sorting_key<'a>( + path: &'a [ChannelId], + channels_by_id: &'a BTreeMap>, +) -> impl 'a + Iterator> { + path.iter() + .map(|id| Some(channels_by_id.get(id)?.name.as_str())) +} diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index 22174f161b..41acafa3a3 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -18,12 +18,10 @@ fn test_update_channels(cx: &mut AppContext) { proto::Channel { id: 1, name: "b".to_string(), - parent_id: None, }, proto::Channel { id: 2, name: "a".to_string(), - parent_id: None, }, ], channel_permissions: vec![proto::ChannelPermission { @@ -51,12 +49,20 @@ fn test_update_channels(cx: &mut AppContext) { proto::Channel { id: 3, name: "x".to_string(), - parent_id: Some(1), }, proto::Channel { id: 4, name: "y".to_string(), - parent_id: Some(2), + }, + ], + insert_edge: vec![ + proto::ChannelEdge { + parent_id: 1, + channel_id: 3, + }, + proto::ChannelEdge { + parent_id: 2, + channel_id: 4, }, ], ..Default::default() @@ -86,17 +92,24 @@ fn test_dangling_channel_paths(cx: &mut AppContext) { 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), + }, + ], + insert_edge: vec![ + proto::ChannelEdge { + parent_id: 0, + channel_id: 1, + }, + proto::ChannelEdge { + parent_id: 1, + channel_id: 2, }, ], channel_permissions: vec![proto::ChannelPermission { @@ -122,7 +135,7 @@ fn test_dangling_channel_paths(cx: &mut AppContext) { update_channels( &channel_store, proto::UpdateChannels { - remove_channels: vec![1, 2], + delete_channels: vec![1, 2], ..Default::default() }, cx, @@ -145,7 +158,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) { channels: vec![proto::Channel { id: channel_id, name: "the-channel".to_string(), - parent_id: None, }], ..Default::default() }); @@ -169,7 +181,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { // Join a channel and populate its existing messages. let channel = channel_store.update(cx, |store, cx| { - let channel_id = store.channels().next().unwrap().1.id; + let channel_id = store.channel_dag_entries().next().unwrap().1.id; store.open_channel_chat(channel_id, cx) }); let join_channel = server.receive::().await.unwrap(); @@ -351,7 +363,7 @@ fn assert_channels( ) { let actual = channel_store.read_with(cx, |store, _| { store - .channels() + .channel_dag_entries() .map(|(depth, channel)| { ( depth, diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 792c65b075..57db1cd761 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -41,6 +41,7 @@ prost.workspace = true rand.workspace = true reqwest = { version = "0.11", features = ["json"], optional = true } scrypt = "0.7" +smallvec.workspace = true # Remove fork dependency when a version with https://github.com/SeaQL/sea-orm/pull/1283 is released. sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls"] } sea-query = "0.27" @@ -72,7 +73,6 @@ fs = { path = "../fs", features = ["test-support"] } git = { path = "../git", features = ["test-support"] } live_kit_client = { path = "../live_kit_client", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] } -pretty_assertions.workspace = true project = { path = "../project", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } @@ -81,6 +81,7 @@ workspace = { path = "../workspace", features = ["test-support"] } collab_ui = { path = "../collab_ui", features = ["test-support"] } async-trait.workspace = true +pretty_assertions.workspace = true ctor.workspace = true env_logger.workspace = true indoc.workspace = true diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index b5d968ddf3..527c4faaa5 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -14,7 +14,10 @@ use collections::{BTreeMap, HashMap, HashSet}; use dashmap::DashMap; use futures::StreamExt; use rand::{prelude::StdRng, Rng, SeedableRng}; -use rpc::{proto, ConnectionId}; +use rpc::{ + proto::{self}, + ConnectionId, +}; use sea_orm::{ entity::prelude::*, ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, @@ -43,6 +46,8 @@ pub use ids::*; pub use sea_orm::ConnectOptions; pub use tables::user::Model as User; +use self::queries::channels::ChannelGraph; + pub struct Database { options: ConnectOptions, pool: DatabaseConnection, @@ -421,16 +426,15 @@ pub struct NewUserResult { pub signup_device_id: Option, } -#[derive(FromQueryResult, Debug, PartialEq)] +#[derive(FromQueryResult, Debug, PartialEq, Eq, Hash)] pub struct Channel { pub id: ChannelId, pub name: String, - pub parent_id: Option, } #[derive(Debug, PartialEq)] pub struct ChannelsForUser { - pub channels: Vec, + pub channels: ChannelGraph, pub channel_participants: HashMap>, pub channels_with_admin_privileges: HashSet, } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 5da4dd1464..2d65139a98 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -1,5 +1,10 @@ +use rpc::proto::ChannelEdge; +use smallvec::SmallVec; + use super::*; +type ChannelDescendants = HashMap>; + impl Database { #[cfg(test)] pub async fn all_channels(&self) -> Result> { @@ -46,7 +51,6 @@ impl Database { .insert(&*tx) .await?; - let channel_paths_stmt; if let Some(parent) = parent { let sql = r#" INSERT INTO channel_paths @@ -58,7 +62,7 @@ impl Database { WHERE channel_id = $3 "#; - channel_paths_stmt = Statement::from_sql_and_values( + let channel_paths_stmt = Statement::from_sql_and_values( self.pool.get_database_backend(), sql, [ @@ -100,7 +104,7 @@ impl Database { .await } - pub async fn remove_channel( + pub async fn delete_channel( &self, channel_id: ChannelId, user_id: UserId, @@ -149,6 +153,19 @@ impl Database { .exec(&*tx) .await?; + // Delete any other paths that include this channel + let sql = r#" + DELETE FROM channel_paths + WHERE + id_path LIKE '%' || $1 || '%' + "#; + let channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [channel_id.to_proto().into()], + ); + tx.execute(channel_paths_stmt).await?; + Ok((channels_to_remove.into_keys().collect(), members_to_notify)) }) .await @@ -310,7 +327,6 @@ impl Database { .map(|channel| Channel { id: channel.id, name: channel.name, - parent_id: None, }) .collect(); @@ -319,6 +335,49 @@ impl Database { .await } + async fn get_channel_graph( + &self, + parents_by_child_id: ChannelDescendants, + trim_dangling_parents: bool, + tx: &DatabaseTransaction, + ) -> Result { + 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, + }) + } + } + + let mut edges = Vec::with_capacity(parents_by_child_id.len()); + for (channel, parents) in parents_by_child_id.iter() { + for parent in parents.into_iter() { + if trim_dangling_parents { + if parents_by_child_id.contains_key(parent) { + edges.push(ChannelEdge { + channel_id: channel.to_proto(), + parent_id: parent.to_proto(), + }); + } + } else { + edges.push(ChannelEdge { + channel_id: channel.to_proto(), + parent_id: parent.to_proto(), + }); + } + } + } + + Ok(ChannelGraph { channels, edges }) + } + pub async fn get_channels_for_user(&self, user_id: UserId) -> Result { self.transaction(|tx| async move { let tx = tx; @@ -332,63 +391,82 @@ impl Database { .all(&*tx) .await?; - let parents_by_child_id = self - .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() - .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(), - }); - } - } - - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] - enum QueryUserIdsAndChannelIds { - ChannelId, - UserId, - } - - let mut channel_participants: 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?; - channel_participants.entry(row.0).or_default().push(row.1) - } - } - - Ok(ChannelsForUser { - channels, - channel_participants, - channels_with_admin_privileges, - }) + self.get_user_channels(channel_memberships, &tx).await }) .await } + pub async fn get_channel_for_user( + &self, + channel_id: ChannelId, + user_id: UserId, + ) -> Result { + self.transaction(|tx| async move { + let tx = tx; + + let channel_membership = channel_member::Entity::find() + .filter( + channel_member::Column::UserId + .eq(user_id) + .and(channel_member::Column::ChannelId.eq(channel_id)) + .and(channel_member::Column::Accepted.eq(true)), + ) + .all(&*tx) + .await?; + + self.get_user_channels(channel_membership, &tx).await + }) + .await + } + + pub async fn get_user_channels( + &self, + channel_memberships: Vec, + tx: &DatabaseTransaction, + ) -> Result { + let parents_by_child_id = self + .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 graph = self + .get_channel_graph(parents_by_child_id, true, &tx) + .await?; + + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryUserIdsAndChannelIds { + ChannelId, + UserId, + } + + let mut channel_participants: HashMap> = HashMap::default(); + { + let mut rows = room_participant::Entity::find() + .inner_join(room::Entity) + .filter(room::Column::ChannelId.is_in(graph.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?; + channel_participants.entry(row.0).or_default().push(row.1) + } + } + + Ok(ChannelsForUser { + channels: graph, + channel_participants, + channels_with_admin_privileges, + }) + } + pub async fn get_channel_members(&self, id: ChannelId) -> Result> { self.transaction(|tx| async move { self.get_channel_members_internal(id, &*tx).await }) .await @@ -559,6 +637,7 @@ impl Database { Ok(()) } + /// Returns the channel ancestors, deepest first pub async fn get_channel_ancestors( &self, channel_id: ChannelId, @@ -566,6 +645,7 @@ impl Database { ) -> Result> { let paths = channel_path::Entity::find() .filter(channel_path::Column::ChannelId.eq(channel_id)) + .order_by(channel_path::Column::IdPath, sea_query::Order::Desc) .all(tx) .await?; let mut channel_ids = Vec::new(); @@ -582,11 +662,25 @@ impl Database { Ok(channel_ids) } + /// Returns the channel descendants, + /// Structured as a map from child ids to their parent ids + /// For example, the descendants of 'a' in this DAG: + /// + /// /- b -\ + /// a -- c -- d + /// + /// would be: + /// { + /// a: [], + /// b: [a], + /// c: [a], + /// d: [a, c], + /// } async fn get_channel_descendants( &self, channel_ids: impl IntoIterator, tx: &DatabaseTransaction, - ) -> Result>> { + ) -> Result { let mut values = String::new(); for id in channel_ids { if !values.is_empty() { @@ -613,7 +707,7 @@ impl Database { let stmt = Statement::from_string(self.pool.get_database_backend(), sql); - let mut parents_by_child_id = HashMap::default(); + let mut parents_by_child_id: ChannelDescendants = HashMap::default(); let mut paths = channel_path::Entity::find() .from_raw_sql(stmt) .stream(tx) @@ -632,7 +726,10 @@ impl Database { parent_id = Some(id); } } - parents_by_child_id.insert(path.channel_id, parent_id); + let entry = parents_by_child_id.entry(path.channel_id).or_default(); + if let Some(parent_id) = parent_id { + entry.insert(parent_id); + } } Ok(parents_by_child_id) @@ -677,7 +774,6 @@ impl Database { Channel { id: channel.id, name: channel.name, - parent_id: None, }, is_accepted, ))) @@ -703,9 +799,276 @@ impl Database { }) .await } + + // Insert an edge from the given channel to the given other channel. + pub async fn link_channel( + &self, + user: UserId, + channel: ChannelId, + to: ChannelId, + ) -> Result { + self.transaction(|tx| async move { + // Note that even with these maxed permissions, this linking operation + // is still insecure because you can't remove someone's permissions to a + // channel if they've linked the channel to one where they're an admin. + self.check_user_is_channel_admin(channel, user, &*tx) + .await?; + + self.link_channel_internal(user, channel, to, &*tx).await + }) + .await + } + + pub async fn link_channel_internal( + &self, + user: UserId, + channel: ChannelId, + to: ChannelId, + tx: &DatabaseTransaction, + ) -> Result { + self.check_user_is_channel_admin(to, user, &*tx).await?; + + let to_ancestors = self.get_channel_ancestors(to, &*tx).await?; + let mut channel_descendants = self.get_channel_descendants([channel], &*tx).await?; + for ancestor in to_ancestors { + if channel_descendants.contains_key(&ancestor) { + return Err(anyhow!("Cannot create a channel cycle").into()); + } + } + + // Now insert all of the new paths + let sql = r#" + INSERT INTO channel_paths + (id_path, channel_id) + SELECT + id_path || $1 || '/', $2 + FROM + channel_paths + WHERE + channel_id = $3 + ON CONFLICT (id_path) DO NOTHING; + "#; + let channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [ + channel.to_proto().into(), + channel.to_proto().into(), + to.to_proto().into(), + ], + ); + tx.execute(channel_paths_stmt).await?; + for (descdenant_id, descendant_parent_ids) in + channel_descendants.iter().filter(|(id, _)| id != &&channel) + { + for descendant_parent_id in descendant_parent_ids.iter() { + let channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [ + descdenant_id.to_proto().into(), + descdenant_id.to_proto().into(), + descendant_parent_id.to_proto().into(), + ], + ); + tx.execute(channel_paths_stmt).await?; + } + } + + // If we're linking a channel, remove any root edges for the channel + { + let sql = r#" + DELETE FROM channel_paths + WHERE + id_path = '/' || $1 || '/' + "#; + let channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [channel.to_proto().into()], + ); + tx.execute(channel_paths_stmt).await?; + } + + if let Some(channel) = channel_descendants.get_mut(&channel) { + // Remove the other parents + channel.clear(); + channel.insert(to); + } + + let channels = self + .get_channel_graph(channel_descendants, false, &*tx) + .await?; + + Ok(channels) + } + + /// Unlink a channel from a given parent. This will add in a root edge if + /// the channel has no other parents after this operation. + pub async fn unlink_channel( + &self, + user: UserId, + channel: ChannelId, + from: ChannelId, + ) -> Result<()> { + self.transaction(|tx| async move { + // Note that even with these maxed permissions, this linking operation + // is still insecure because you can't remove someone's permissions to a + // channel if they've linked the channel to one where they're an admin. + self.check_user_is_channel_admin(channel, user, &*tx) + .await?; + + self.unlink_channel_internal(user, channel, from, &*tx) + .await?; + + Ok(()) + }) + .await + } + + pub async fn unlink_channel_internal( + &self, + user: UserId, + channel: ChannelId, + from: ChannelId, + tx: &DatabaseTransaction, + ) -> Result<()> { + self.check_user_is_channel_admin(from, user, &*tx).await?; + + let sql = r#" + DELETE FROM channel_paths + WHERE + id_path LIKE '%' || $1 || '/' || $2 || '%' + "#; + let channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [from.to_proto().into(), channel.to_proto().into()], + ); + tx.execute(channel_paths_stmt).await?; + + // Make sure that there is always at least one path to the channel + let sql = r#" + INSERT INTO channel_paths + (id_path, channel_id) + SELECT + '/' || $1 || '/', $2 + WHERE NOT EXISTS + (SELECT * + FROM channel_paths + WHERE channel_id = $2) + "#; + + let channel_paths_stmt = Statement::from_sql_and_values( + self.pool.get_database_backend(), + sql, + [channel.to_proto().into(), channel.to_proto().into()], + ); + tx.execute(channel_paths_stmt).await?; + + Ok(()) + } + + /// Move a channel from one parent to another, returns the + /// Channels that were moved for notifying clients + pub async fn move_channel( + &self, + user: UserId, + channel: ChannelId, + from: ChannelId, + to: ChannelId, + ) -> Result { + self.transaction(|tx| async move { + self.check_user_is_channel_admin(channel, user, &*tx) + .await?; + + let moved_channels = self.link_channel_internal(user, channel, to, &*tx).await?; + + self.unlink_channel_internal(user, channel, from, &*tx) + .await?; + + Ok(moved_channels) + }) + .await + } } #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] enum QueryUserIds { UserId, } + +#[derive(Debug)] +pub struct ChannelGraph { + pub channels: Vec, + pub edges: Vec, +} + +impl ChannelGraph { + pub fn is_empty(&self) -> bool { + self.channels.is_empty() && self.edges.is_empty() + } +} + +#[cfg(test)] +impl PartialEq for ChannelGraph { + fn eq(&self, other: &Self) -> bool { + // Order independent comparison for tests + let channels_set = self.channels.iter().collect::>(); + let other_channels_set = other.channels.iter().collect::>(); + let edges_set = self + .edges + .iter() + .map(|edge| (edge.channel_id, edge.parent_id)) + .collect::>(); + let other_edges_set = other + .edges + .iter() + .map(|edge| (edge.channel_id, edge.parent_id)) + .collect::>(); + + channels_set == other_channels_set && edges_set == other_edges_set + } +} + +#[cfg(not(test))] +impl PartialEq for ChannelGraph { + fn eq(&self, other: &Self) -> bool { + self.channels == other.channels && self.edges == other.edges + } +} + +struct SmallSet(SmallVec<[T; 1]>); + +impl Deref for SmallSet { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.0.deref() + } +} + +impl Default for SmallSet { + fn default() -> Self { + Self(SmallVec::new()) + } +} + +impl SmallSet { + fn insert(&mut self, value: T) -> bool + where + T: Ord, + { + match self.binary_search(&value) { + Ok(_) => false, + Err(ix) => { + self.0.insert(ix, value); + true + } + } + } + + fn clear(&mut self) { + self.0.clear(); + } +} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 1d6c550865..cf12be9b8d 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -1,4 +1,5 @@ mod buffer_tests; +mod channel_tests; mod db_tests; mod feature_flag_tests; mod message_tests; @@ -6,6 +7,7 @@ mod message_tests; use super::*; use gpui::executor::Background; use parking_lot::Mutex; +use rpc::proto::ChannelEdge; use sea_orm::ConnectionTrait; use sqlx::migrate::MigrateDatabase; use std::sync::Arc; @@ -143,3 +145,27 @@ impl Drop for TestDb { } } } + +/// The second tuples are (channel_id, parent) +fn graph(channels: &[(ChannelId, &'static str)], edges: &[(ChannelId, ChannelId)]) -> ChannelGraph { + let mut graph = ChannelGraph { + channels: vec![], + edges: vec![], + }; + + for (id, name) in channels { + graph.channels.push(Channel { + id: *id, + name: name.to_string(), + }) + } + + for (channel, parent) in edges { + graph.edges.push(ChannelEdge { + channel_id: channel.to_proto(), + parent_id: parent.to_proto(), + }) + } + + graph +} diff --git a/crates/collab/src/db/tests/channel_tests.rs b/crates/collab/src/db/tests/channel_tests.rs new file mode 100644 index 0000000000..8a03014cf9 --- /dev/null +++ b/crates/collab/src/db/tests/channel_tests.rs @@ -0,0 +1,817 @@ +use collections::{HashMap, HashSet}; +use rpc::{ + proto::{self}, + ConnectionId, +}; + +use crate::{ + db::{queries::channels::ChannelGraph, tests::graph, ChannelId, Database, NewUserParams}, + test_both_dbs, +}; +use std::sync::Arc; + +test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite); + +async fn test_channels(db: &Arc) { + 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 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(); + + // 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(); + + 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 + .unwrap(); + let livestreaming_id = db + .create_channel("livestreaming", Some(zed_id), "3", a_id) + .await + .unwrap(); + let replace_id = 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) + .await + .unwrap(); + + let cargo_ra_id = db + .create_channel("cargo-ra", Some(cargo_id), "7", a_id) + .await + .unwrap(); + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_eq!( + result.channels, + graph( + &[ + (zed_id, "zed"), + (crdb_id, "crdb"), + (livestreaming_id, "livestreaming"), + (replace_id, "replace"), + (rust_id, "rust"), + (cargo_id, "cargo"), + (cargo_ra_id, "cargo-ra") + ], + &[ + (crdb_id, zed_id), + (livestreaming_id, zed_id), + (replace_id, zed_id), + (cargo_id, rust_id), + (cargo_ra_id, cargo_id), + ] + ) + ); + + let result = db.get_channels_for_user(b_id).await.unwrap(); + assert_eq!( + result.channels, + graph( + &[ + (zed_id, "zed"), + (crdb_id, "crdb"), + (livestreaming_id, "livestreaming"), + (replace_id, "replace") + ], + &[ + (crdb_id, zed_id), + (livestreaming_id, zed_id), + (replace_id, zed_id) + ] + ) + ); + + // 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 result = db.get_channels_for_user(b_id).await.unwrap(); + assert_eq!( + result.channels, + graph( + &[ + (zed_id, "zed"), + (crdb_id, "crdb"), + (livestreaming_id, "livestreaming"), + (replace_id, "replace") + ], + &[ + (crdb_id, zed_id), + (livestreaming_id, zed_id), + (replace_id, zed_id) + ] + ) + ); + + // Remove a single channel + db.delete_channel(crdb_id, a_id).await.unwrap(); + assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none()); + + // Remove a channel tree + let (mut channel_ids, user_ids) = db.delete_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, 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!( + test_joining_channels, + test_joining_channels_postgres, + test_joining_channels_sqlite +); + +async fn test_joining_channels(db: &Arc) { + 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", "1", user_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 joined_room = db + .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 }) + .await + .unwrap(); + assert_eq!(joined_room.room.participants.len(), 1); + + drop(joined_room); + // cannot join a room without membership to its channel + assert!(db + .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 }) + .await + .is_err()); +} + +test_both_dbs!( + test_channel_invites, + test_channel_invites_postgres, + test_channel_invites_sqlite +); + +async fn test_channel_invites(db: &Arc) { + 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 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, true) + .await + .unwrap(); + + let user_2_invites = db + .get_channel_invites_for_user(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_for_user(user_3) // -> [channel_1_1] + .await + .unwrap() + .into_iter() + .map(|channel| channel.id) + .collect::>(); + + assert_eq!(user_3_invites, &[channel_1_1]); + + let members = db + .get_channel_member_details(channel_1_1, user_1) + .await + .unwrap(); + assert_eq!( + members, + &[ + 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, + }, + ] + ); + + 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, user_1) + .await + .unwrap(); + assert_eq!( + members, + &[ + 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, + }, + ] + ); +} + +test_both_dbs!( + test_channel_renames, + test_channel_renames_postgres, + test_channel_renames_sqlite +); + +async fn test_channel_renames(db: &Arc) { + 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()) +} + +test_both_dbs!( + test_db_channel_moving, + test_channels_moving_postgres, + test_channels_moving_sqlite +); + +async fn test_db_channel_moving(db: &Arc) { + 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", "1", a_id).await.unwrap(); + + let crdb_id = db + .create_channel("crdb", Some(zed_id), "2", a_id) + .await + .unwrap(); + + let gpui2_id = db + .create_channel("gpui2", Some(zed_id), "3", a_id) + .await + .unwrap(); + + let livestreaming_id = db + .create_channel("livestreaming", Some(crdb_id), "4", a_id) + .await + .unwrap(); + + let livestreaming_dag_id = db + .create_channel("livestreaming_dag", Some(livestreaming_id), "5", a_id) + .await + .unwrap(); + + // ======================================================================== + // sanity check + // Initial DAG: + // /- gpui2 + // zed -- crdb - livestreaming - livestreaming_dag + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + ], + ); + + // Attempt to make a cycle + assert!(db + .link_channel(a_id, zed_id, livestreaming_id) + .await + .is_err()); + + // ======================================================================== + // Make a link + db.link_channel(a_id, livestreaming_id, zed_id) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 + // zed -- crdb - livestreaming - livestreaming_dag + // \---------/ + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + ], + ); + + // ======================================================================== + // Create a new channel below a channel with multiple parents + let livestreaming_dag_sub_id = db + .create_channel( + "livestreaming_dag_sub", + Some(livestreaming_dag_id), + "6", + a_id, + ) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 + // zed -- crdb - livestreaming - livestreaming_dag - livestreaming_dag_sub_id + // \---------/ + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Test a complex DAG by making another link + let returned_channels = db + .link_channel(a_id, livestreaming_dag_sub_id, livestreaming_id) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 /---------------------\ + // zed - crdb - livestreaming - livestreaming_dag - livestreaming_dag_sub_id + // \--------/ + + // make sure we're getting just the new link + // Not using the assert_dag helper because we want to make sure we're returning the full data + pretty_assertions::assert_eq!( + returned_channels, + graph( + &[(livestreaming_dag_sub_id, "livestreaming_dag_sub")], + &[(livestreaming_dag_sub_id, livestreaming_id)] + ) + ); + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Test a complex DAG by making another link + let returned_channels = db + .link_channel(a_id, livestreaming_id, gpui2_id) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 -\ /---------------------\ + // zed - crdb -- livestreaming - livestreaming_dag - livestreaming_dag_sub_id + // \---------/ + + // Make sure that we're correctly getting the full sub-dag + pretty_assertions::assert_eq!( + returned_channels, + graph( + &[ + (livestreaming_id, "livestreaming"), + (livestreaming_dag_id, "livestreaming_dag"), + (livestreaming_dag_sub_id, "livestreaming_dag_sub"), + ], + &[ + (livestreaming_id, gpui2_id), + (livestreaming_dag_id, livestreaming_id), + (livestreaming_dag_sub_id, livestreaming_id), + (livestreaming_dag_sub_id, livestreaming_dag_id), + ] + ) + ); + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_id, Some(gpui2_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Test unlinking in a complex DAG by removing the inner link + db.unlink_channel(a_id, livestreaming_dag_sub_id, livestreaming_id) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 -\ + // zed - crdb -- livestreaming - livestreaming_dag - livestreaming_dag_sub + // \---------/ + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(gpui2_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Test unlinking in a complex DAG by removing the inner link + db.unlink_channel(a_id, livestreaming_id, gpui2_id) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 + // zed - crdb -- livestreaming - livestreaming_dag - livestreaming_dag_sub + // \---------/ + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Test moving DAG nodes by moving livestreaming to be below gpui2 + db.move_channel(a_id, livestreaming_id, crdb_id, gpui2_id) + .await + .unwrap(); + + // DAG is now: + // /- gpui2 -- livestreaming - livestreaming_dag - livestreaming_dag_sub + // zed - crdb / + // \---------/ + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (gpui2_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(gpui2_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Deleting a channel should not delete children that still have other parents + db.delete_channel(gpui2_id, a_id).await.unwrap(); + + // DAG is now: + // zed - crdb + // \- livestreaming - livestreaming_dag - livestreaming_dag_sub + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Unlinking a channel from it's parent should automatically promote it to a root channel + db.unlink_channel(a_id, crdb_id, zed_id).await.unwrap(); + + // DAG is now: + // crdb + // zed + // \- livestreaming - livestreaming_dag - livestreaming_dag_sub + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, None), + (livestreaming_id, Some(zed_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // You should be able to move a root channel into a non-root channel + db.link_channel(a_id, crdb_id, zed_id).await.unwrap(); + + // DAG is now: + // zed - crdb + // \- livestreaming - livestreaming_dag - livestreaming_dag_sub + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // ======================================================================== + // Prep for DAG deletion test + db.link_channel(a_id, livestreaming_id, crdb_id) + .await + .unwrap(); + + // DAG is now: + // zed - crdb - livestreaming - livestreaming_dag - livestreaming_dag_sub + // \--------/ + + let result = db.get_channels_for_user(a_id).await.unwrap(); + assert_dag( + result.channels, + &[ + (zed_id, None), + (crdb_id, Some(zed_id)), + (livestreaming_id, Some(zed_id)), + (livestreaming_id, Some(crdb_id)), + (livestreaming_dag_id, Some(livestreaming_id)), + (livestreaming_dag_sub_id, Some(livestreaming_dag_id)), + ], + ); + + // Deleting the parent of a DAG should delete the whole DAG: + db.delete_channel(zed_id, a_id).await.unwrap(); + let result = db.get_channels_for_user(a_id).await.unwrap(); + + assert!(result.channels.is_empty()) +} + +#[track_caller] +fn assert_dag(actual: ChannelGraph, expected: &[(ChannelId, Option)]) { + let mut actual_map: HashMap> = HashMap::default(); + for channel in actual.channels { + actual_map.insert(channel.id, HashSet::default()); + } + for edge in actual.edges { + actual_map + .get_mut(&ChannelId::from_proto(edge.channel_id)) + .unwrap() + .insert(ChannelId::from_proto(edge.parent_id)); + } + + let mut expected_map: HashMap> = HashMap::default(); + + for (child, parent) in expected { + let entry = expected_map.entry(*child).or_default(); + if let Some(parent) = parent { + entry.insert(*parent); + } + } + + pretty_assertions::assert_eq!(actual_map, expected_map) +} diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 0e6a0529c4..9a617166fe 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -575,458 +575,6 @@ async fn test_fuzzy_search_users() { } } -test_both_dbs!(test_channels, test_channels_postgres, test_channels_sqlite); - -async fn test_channels(db: &Arc) { - 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 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(); - - // 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(); - - 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 - .unwrap(); - let livestreaming_id = db - .create_channel("livestreaming", Some(zed_id), "3", a_id) - .await - .unwrap(); - let replace_id = 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) - .await - .unwrap(); - - let cargo_ra_id = db - .create_channel("cargo-ra", Some(cargo_id), "7", a_id) - .await - .unwrap(); - - let result = db.get_channels_for_user(a_id).await.unwrap(); - assert_eq!( - result.channels, - vec![ - Channel { - id: zed_id, - name: "zed".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: rust_id, - name: "rust".to_string(), - parent_id: None, - }, - Channel { - 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), - } - ] - ); - - let result = db.get_channels_for_user(b_id).await.unwrap(); - assert_eq!( - result.channels, - vec![ - Channel { - id: zed_id, - name: "zed".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), - }, - ] - ); - - // 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 result = db.get_channels_for_user(b_id).await.unwrap(); - assert_eq!( - result.channels, - vec![ - Channel { - id: zed_id, - name: "zed".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), - }, - ] - ); - - // 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()); - - // 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, 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!( - test_joining_channels, - test_joining_channels_postgres, - test_joining_channels_sqlite -); - -async fn test_joining_channels(db: &Arc) { - 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", "1", user_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 joined_room = db - .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 }) - .await - .unwrap(); - assert_eq!(joined_room.room.participants.len(), 1); - - drop(joined_room); - // cannot join a room without membership to its channel - assert!(db - .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 }) - .await - .is_err()); -} - -test_both_dbs!( - test_channel_invites, - test_channel_invites_postgres, - test_channel_invites_sqlite -); - -async fn test_channel_invites(db: &Arc) { - 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 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, true) - .await - .unwrap(); - - let user_2_invites = db - .get_channel_invites_for_user(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_for_user(user_3) // -> [channel_1_1] - .await - .unwrap() - .into_iter() - .map(|channel| channel.id) - .collect::>(); - - assert_eq!(user_3_invites, &[channel_1_1]); - - let members = db - .get_channel_member_details(channel_1_1, user_1) - .await - .unwrap(); - assert_eq!( - members, - &[ - 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, - }, - ] - ); - - 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, user_1) - .await - .unwrap(); - assert_eq!( - members, - &[ - 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, - }, - ] - ); -} - -test_both_dbs!( - test_channel_renames, - test_channel_renames_postgres, - test_channel_renames_sqlite -); - -async fn test_channel_renames(db: &Arc) { - 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()) -} - fn build_background_executor() -> Arc { Deterministic::new(0).build_background() } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 3289daf6ca..87b7720235 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -38,8 +38,8 @@ use lazy_static::lazy_static; use prometheus::{register_int_gauge, IntGauge}; use rpc::{ proto::{ - self, Ack, AddChannelBufferCollaborator, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, - LiveKitConnectionInfo, RequestMessage, + self, Ack, AddChannelBufferCollaborator, AnyTypedEnvelope, ChannelEdge, EntityMessage, + EnvelopedMessage, LiveKitConnectionInfo, RequestMessage, }, Connection, ConnectionId, Peer, Receipt, TypedEnvelope, }; @@ -250,7 +250,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(delete_channel) .add_request_handler(invite_channel_member) .add_request_handler(remove_channel_member) .add_request_handler(set_channel_member_admin) @@ -267,6 +267,9 @@ impl Server { .add_request_handler(send_channel_message) .add_request_handler(remove_channel_message) .add_request_handler(get_channel_messages) + .add_request_handler(link_channel) + .add_request_handler(unlink_channel) + .add_request_handler(move_channel) .add_request_handler(follow) .add_message_handler(unfollow) .add_message_handler(update_followers) @@ -2197,56 +2200,58 @@ async fn create_channel( let channel = proto::Channel { id: id.to_proto(), name: request.name, - parent_id: request.parent_id, }; - response.send(proto::ChannelResponse { + response.send(proto::CreateChannelResponse { channel: Some(channel.clone()), + parent_id: request.parent_id, })?; - 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? - } else { - vec![session.user_id] + let Some(parent_id) = parent_id else { + return Ok(()); }; + let update = proto::UpdateChannels { + channels: vec![channel], + insert_edge: vec![ChannelEdge { + parent_id: parent_id.to_proto(), + channel_id: id.to_proto(), + }], + ..Default::default() + }; + + let user_ids_to_notify = db.get_channel_members(parent_id).await?; + 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.channel_permissions.push(proto::ChannelPermission { - channel_id: id.to_proto(), - is_admin: true, - }); + continue; } - session.peer.send(connection_id, update)?; + session.peer.send(connection_id, update.clone())?; } } Ok(()) } -async fn remove_channel( - request: proto::RemoveChannel, - response: Response, +async fn delete_channel( + request: proto::DeleteChannel, + 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) + .delete_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 + .delete_channels .extend(removed_channels.into_iter().map(|id| id.to_proto())); let connection_pool = session.connection_pool().await; @@ -2279,7 +2284,6 @@ async fn invite_channel_member( update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, - parent_id: None, }); for connection_id in session .connection_pool() @@ -2306,7 +2310,7 @@ async fn remove_channel_member( .await?; let mut update = proto::UpdateChannels::default(); - update.remove_channels.push(channel_id.to_proto()); + update.delete_channels.push(channel_id.to_proto()); for connection_id in session .connection_pool() @@ -2370,9 +2374,8 @@ async fn rename_channel( let channel = proto::Channel { id: request.channel_id, name: new_name, - parent_id: None, }; - response.send(proto::ChannelResponse { + response.send(proto::RenameChannelResponse { channel: Some(channel.clone()), })?; let mut update = proto::UpdateChannels::default(); @@ -2390,6 +2393,127 @@ async fn rename_channel( Ok(()) } +async fn link_channel( + request: proto::LinkChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let to = ChannelId::from_proto(request.to); + let channels_to_send = db.link_channel(session.user_id, channel_id, to).await?; + + let members = db.get_channel_members(to).await?; + let connection_pool = session.connection_pool().await; + let update = proto::UpdateChannels { + channels: channels_to_send + .channels + .into_iter() + .map(|channel| proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + }) + .collect(), + insert_edge: channels_to_send.edges, + ..Default::default() + }; + for member_id in members { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + + response.send(Ack {})?; + + Ok(()) +} + +async fn unlink_channel( + request: proto::UnlinkChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let from = ChannelId::from_proto(request.from); + + db.unlink_channel(session.user_id, channel_id, from).await?; + + let members = db.get_channel_members(from).await?; + + let update = proto::UpdateChannels { + delete_edge: vec![proto::ChannelEdge { + channel_id: channel_id.to_proto(), + parent_id: from.to_proto(), + }], + ..Default::default() + }; + let connection_pool = session.connection_pool().await; + for member_id in members { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + + response.send(Ack {})?; + + Ok(()) +} + +async fn move_channel( + request: proto::MoveChannel, + response: Response, + session: Session, +) -> Result<()> { + let db = session.db().await; + let channel_id = ChannelId::from_proto(request.channel_id); + let from_parent = ChannelId::from_proto(request.from); + let to = ChannelId::from_proto(request.to); + + let channels_to_send = db + .move_channel(session.user_id, channel_id, from_parent, to) + .await?; + + let members_from = db.get_channel_members(from_parent).await?; + let members_to = db.get_channel_members(to).await?; + + let update = proto::UpdateChannels { + delete_edge: vec![proto::ChannelEdge { + channel_id: channel_id.to_proto(), + parent_id: from_parent.to_proto(), + }], + ..Default::default() + }; + let connection_pool = session.connection_pool().await; + for member_id in members_from { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + + let update = proto::UpdateChannels { + channels: channels_to_send + .channels + .into_iter() + .map(|channel| proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + }) + .collect(), + insert_edge: channels_to_send.edges, + ..Default::default() + }; + for member_id in members_to { + for connection_id in connection_pool.user_connection_ids(member_id) { + session.peer.send(connection_id, update.clone())?; + } + } + + response.send(Ack {})?; + + Ok(()) +} + async fn get_channel_members( request: proto::GetChannelMembers, response: Response, @@ -2419,14 +2543,20 @@ async fn respond_to_channel_invite( .remove_channel_invitations .push(channel_id.to_proto()); if request.accept { - let result = db.get_channels_for_user(session.user_id).await?; + let result = db.get_channel_for_user(channel_id, session.user_id).await?; update .channels - .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), - })); + .extend( + result + .channels + .channels + .into_iter() + .map(|channel| proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + }), + ); + update.insert_edge = result.channels.edges; update .channel_participants .extend( @@ -2844,14 +2974,15 @@ fn build_initial_channels_update( ) -> proto::UpdateChannels { let mut update = proto::UpdateChannels::default(); - for channel in channels.channels { + for channel in channels.channels.channels { update.channels.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, - parent_id: channel.parent_id.map(|id| id.to_proto()), }); } + update.insert_edge = channels.channels.edges; + for (channel_id, participants) in channels.channel_participants { update .channel_participants @@ -2877,7 +3008,6 @@ fn build_initial_channels_update( update.channel_invitations.push(proto::Channel { id: channel.id.to_proto(), name: channel.name, - parent_id: None, }); } diff --git a/crates/collab/src/tests/channel_buffer_tests.rs b/crates/collab/src/tests/channel_buffer_tests.rs index fe286895b4..ba5a70895a 100644 --- a/crates/collab/src/tests/channel_buffer_tests.rs +++ b/crates/collab/src/tests/channel_buffer_tests.rs @@ -25,7 +25,7 @@ async fn test_core_channel_buffers( let client_b = server.create_client(cx_b, "user_b").await; let channel_id = server - .make_channel("zed", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) .await; // Client A joins the channel buffer @@ -135,6 +135,7 @@ async fn test_channel_buffer_replica_ids( let channel_id = server .make_channel( "the-channel", + None, (&client_a, cx_a), &mut [(&client_b, cx_b), (&client_c, cx_c)], ) @@ -279,7 +280,7 @@ async fn test_reopen_channel_buffer(deterministic: Arc, cx_a: &mu let client_a = server.create_client(cx_a, "user_a").await; let channel_id = server - .make_channel("the-channel", (&client_a, cx_a), &mut []) + .make_channel("the-channel", None, (&client_a, cx_a), &mut []) .await; let channel_buffer_1 = client_a @@ -341,7 +342,12 @@ async fn test_channel_buffer_disconnect( let client_b = server.create_client(cx_b, "user_b").await; let channel_id = server - .make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) .await; let channel_buffer_a = client_a @@ -411,7 +417,12 @@ async fn test_rejoin_channel_buffer( let client_b = server.create_client(cx_b, "user_b").await; let channel_id = server - .make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) .await; let channel_buffer_a = client_a @@ -491,6 +502,7 @@ async fn test_channel_buffers_and_server_restarts( let channel_id = server .make_channel( "the-channel", + None, (&client_a, cx_a), &mut [(&client_b, cx_b), (&client_c, cx_c)], ) diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index 1a9460c6cf..58494c538b 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -15,7 +15,12 @@ async fn test_basic_channel_messages( let client_b = server.create_client(cx_b, "user_b").await; let channel_id = server - .make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) .await; let channel_chat_a = client_a @@ -68,7 +73,12 @@ async fn test_rejoin_channel_chat( let client_b = server.create_client(cx_b, "user_b").await; let channel_id = server - .make_channel("the-channel", (&client_a, cx_a), &mut [(&client_b, cx_b)]) + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) .await; let channel_chat_a = client_a @@ -139,6 +149,7 @@ async fn test_remove_channel_message( let channel_id = server .make_channel( "the-channel", + None, (&client_a, cx_a), &mut [(&client_b, cx_b), (&client_c, cx_c)], ) diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index b54b4d349b..6b300282a1 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -56,7 +56,10 @@ async fn test_core_channels( ); client_b.channel_store().read_with(cx_b, |channels, _| { - assert!(channels.channels().collect::>().is_empty()) + assert!(channels + .channel_dag_entries() + .collect::>() + .is_empty()) }); // Invite client B to channel A as client A. @@ -142,6 +145,8 @@ async fn test_core_channels( ], ); + println!("STARTING CREATE CHANNEL C"); + let channel_c_id = client_a .channel_store() .update(cx_a, |channel_store, cx| { @@ -326,7 +331,7 @@ async fn test_joining_channel_ancestor_member( 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)]) + .make_channel("parent", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) .await; let sub_id = client_a @@ -361,6 +366,7 @@ async fn test_channel_room( let zed_id = server .make_channel( "zed", + None, (&client_a, cx_a), &mut [(&client_b, cx_b), (&client_c, cx_c)], ) @@ -544,9 +550,11 @@ async fn test_channel_jumping(deterministic: Arc, cx_a: &mut Test 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 zed_id = server + .make_channel("zed", None, (&client_a, cx_a), &mut []) + .await; let rust_id = server - .make_channel("rust", (&client_a, cx_a), &mut []) + .make_channel("rust", None, (&client_a, cx_a), &mut []) .await; let active_call_a = cx_a.read(ActiveCall::global); @@ -597,7 +605,7 @@ async fn test_permissions_update_while_invited( let client_b = server.create_client(cx_b, "user_b").await; let rust_id = server - .make_channel("rust", (&client_a, cx_a), &mut []) + .make_channel("rust", None, (&client_a, cx_a), &mut []) .await; client_a @@ -658,7 +666,7 @@ async fn test_channel_rename( 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)]) + .make_channel("rust", None, (&client_a, cx_a), &mut [(&client_b, cx_b)]) .await; // Rename the channel @@ -716,6 +724,7 @@ async fn test_call_from_channel( let channel_id = server .make_channel( "x", + None, (&client_a, cx_a), &mut [(&client_b, cx_b), (&client_c, cx_c)], ) @@ -786,7 +795,9 @@ async fn test_lost_channel_creation( .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; + let channel_id = server + .make_channel("x", None, (&client_a, cx_a), &mut []) + .await; // Invite a member client_a @@ -874,6 +885,257 @@ async fn test_lost_channel_creation( ); } +#[gpui::test] +async fn test_channel_moving( + 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; + + let channels = server + .make_channel_tree( + &[ + ("channel-a", None), + ("channel-b", Some("channel-a")), + ("channel-c", Some("channel-b")), + ("channel-d", Some("channel-c")), + ], + (&client_a, cx_a), + ) + .await; + let channel_a_id = channels[0]; + let channel_b_id = channels[1]; + let channel_c_id = channels[2]; + let channel_d_id = channels[3]; + + // Current shape: + // a - b - c - d + assert_channels_list_shape( + client_a.channel_store(), + cx_a, + &[ + (channel_a_id, 0), + (channel_b_id, 1), + (channel_c_id, 2), + (channel_d_id, 3), + ], + ); + + client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.move_channel(channel_d_id, channel_c_id, channel_b_id, cx) + }) + .await + .unwrap(); + + // Current shape: + // /- d + // a - b -- c + assert_channels_list_shape( + client_a.channel_store(), + cx_a, + &[ + (channel_a_id, 0), + (channel_b_id, 1), + (channel_c_id, 2), + (channel_d_id, 2), + ], + ); + + client_a + .channel_store() + .update(cx_a, |channel_store, cx| { + channel_store.link_channel(channel_d_id, channel_c_id, cx) + }) + .await + .unwrap(); + + // Current shape for A: + // /------\ + // a - b -- c -- d + assert_channels_list_shape( + client_a.channel_store(), + cx_a, + &[ + (channel_a_id, 0), + (channel_b_id, 1), + (channel_c_id, 2), + (channel_d_id, 3), + (channel_d_id, 2), + ], + ); + + let b_channels = server + .make_channel_tree( + &[ + ("channel-mu", None), + ("channel-gamma", Some("channel-mu")), + ("channel-epsilon", Some("channel-mu")), + ], + (&client_b, cx_b), + ) + .await; + let channel_mu_id = b_channels[0]; + let channel_ga_id = b_channels[1]; + let channel_ep_id = b_channels[2]; + + // Current shape for B: + // /- ep + // mu -- ga + assert_channels_list_shape( + client_b.channel_store(), + cx_b, + &[(channel_mu_id, 0), (channel_ep_id, 1), (channel_ga_id, 1)], + ); + + client_a + .add_admin_to_channel((&client_b, cx_b), channel_b_id, cx_a) + .await; + + // Current shape for B: + // /- ep + // mu -- ga + // /---------\ + // b -- c -- d + assert_channels_list_shape( + client_b.channel_store(), + cx_b, + &[ + // New channels from a + (channel_b_id, 0), + (channel_c_id, 1), + (channel_d_id, 2), + (channel_d_id, 1), + // B's old channels + (channel_mu_id, 0), + (channel_ep_id, 1), + (channel_ga_id, 1), + ], + ); + + client_b + .add_admin_to_channel((&client_c, cx_c), channel_ep_id, cx_b) + .await; + + // Current shape for C: + // - ep + assert_channels_list_shape(client_c.channel_store(), cx_c, &[(channel_ep_id, 0)]); + + println!("*******************************************"); + println!("********** STARTING LINK CHANNEL **********"); + println!("*******************************************"); + dbg!(client_b.user_id()); + client_b + .channel_store() + .update(cx_b, |channel_store, cx| { + channel_store.link_channel(channel_b_id, channel_ep_id, cx) + }) + .await + .unwrap(); + + // Current shape for B: + // /---------\ + // /- ep -- b -- c -- d + // mu -- ga + assert_channels_list_shape( + client_b.channel_store(), + cx_b, + &[ + (channel_mu_id, 0), + (channel_ep_id, 1), + (channel_b_id, 2), + (channel_c_id, 3), + (channel_d_id, 4), + (channel_d_id, 3), + (channel_ga_id, 1), + ], + ); + + // Current shape for C: + // /---------\ + // ep -- b -- c -- d + assert_channels_list_shape( + client_c.channel_store(), + cx_c, + &[ + (channel_ep_id, 0), + (channel_b_id, 1), + (channel_c_id, 2), + (channel_d_id, 3), + (channel_d_id, 2), + ], + ); + + client_b + .channel_store() + .update(cx_b, |channel_store, cx| { + channel_store.link_channel(channel_ga_id, channel_b_id, cx) + }) + .await + .unwrap(); + + // Current shape for B: + // /---------\ + // /- ep -- b -- c -- d + // / \ + // mu ---------- ga + assert_channels_list_shape( + client_b.channel_store(), + cx_b, + &[ + (channel_mu_id, 0), + (channel_ep_id, 1), + (channel_b_id, 2), + (channel_c_id, 3), + (channel_d_id, 4), + (channel_d_id, 3), + (channel_ga_id, 3), + (channel_ga_id, 1), + ], + ); + + // Current shape for A: + // /------\ + // a - b -- c -- d + // \-- ga + assert_channels_list_shape( + client_a.channel_store(), + cx_a, + &[ + (channel_a_id, 0), + (channel_b_id, 1), + (channel_c_id, 2), + (channel_d_id, 3), + (channel_d_id, 2), + (channel_ga_id, 2), + ], + ); + + // Current shape for C: + // /-------\ + // ep -- b -- c -- d + // \-- ga + assert_channels_list_shape( + client_c.channel_store(), + cx_c, + &[ + (channel_ep_id, 0), + (channel_b_id, 1), + (channel_c_id, 2), + (channel_d_id, 3), + (channel_d_id, 2), + (channel_ga_id, 2), + ], + ); +} + #[derive(Debug, PartialEq)] struct ExpectedChannel { depth: usize, @@ -911,7 +1173,7 @@ fn assert_channels( ) { let actual = channel_store.read_with(cx, |store, _| { store - .channels() + .channel_dag_entries() .map(|(depth, channel)| ExpectedChannel { depth, name: channel.name.clone(), @@ -920,5 +1182,22 @@ fn assert_channels( }) .collect::>() }); - assert_eq!(actual, expected_channels); + pretty_assertions::assert_eq!(actual, expected_channels); +} + +#[track_caller] +fn assert_channels_list_shape( + channel_store: &ModelHandle, + cx: &TestAppContext, + expected_channels: &[(u64, usize)], +) { + cx.foreground().run_until_parked(); + + let actual = channel_store.read_with(cx, |store, _| { + store + .channel_dag_entries() + .map(|(depth, channel)| (channel.id, depth)) + .collect::>() + }); + pretty_assertions::assert_eq!(dbg!(actual), expected_channels); } diff --git a/crates/collab/src/tests/random_channel_buffer_tests.rs b/crates/collab/src/tests/random_channel_buffer_tests.rs index a60d3d7d7d..2950922e7c 100644 --- a/crates/collab/src/tests/random_channel_buffer_tests.rs +++ b/crates/collab/src/tests/random_channel_buffer_tests.rs @@ -86,7 +86,7 @@ impl RandomizedTest for RandomChannelBufferTest { match rng.gen_range(0..100_u32) { 0..=29 => { let channel_name = client.channel_store().read_with(cx, |store, cx| { - store.channels().find_map(|(_, channel)| { + store.channel_dag_entries().find_map(|(_, channel)| { if store.has_open_channel_buffer(channel.id, cx) { None } else { @@ -133,7 +133,7 @@ impl RandomizedTest for RandomChannelBufferTest { ChannelBufferOperation::JoinChannelNotes { channel_name } => { let buffer = client.channel_store().update(cx, |store, cx| { let channel_id = store - .channels() + .channel_dag_entries() .find(|(_, c)| c.name == channel_name) .unwrap() .1 diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index a7dbd97239..6572722df3 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -288,6 +288,7 @@ impl TestServer { pub async fn make_channel( &self, channel: &str, + parent: Option, admin: (&TestClient, &mut TestAppContext), members: &mut [(&TestClient, &mut TestAppContext)], ) -> u64 { @@ -296,7 +297,7 @@ impl TestServer { .app_state .channel_store .update(admin_cx, |channel_store, cx| { - channel_store.create_channel(channel, None, cx) + channel_store.create_channel(channel, parent, cx) }) .await .unwrap(); @@ -331,6 +332,39 @@ impl TestServer { channel_id } + pub async fn make_channel_tree( + &self, + channels: &[(&str, Option<&str>)], + creator: (&TestClient, &mut TestAppContext), + ) -> Vec { + let mut observed_channels = HashMap::default(); + let mut result = Vec::new(); + for (channel, parent) in channels { + let id; + if let Some(parent) = parent { + if let Some(parent_id) = observed_channels.get(parent) { + id = self + .make_channel(channel, Some(*parent_id), (creator.0, creator.1), &mut []) + .await; + } else { + panic!( + "Edge {}->{} referenced before {} was created", + parent, channel, parent + ) + } + } else { + id = self + .make_channel(channel, None, (creator.0, creator.1), &mut []) + .await; + } + + observed_channels.insert(channel, id); + result.push(id); + } + + result + } + pub async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) { self.make_contacts(clients).await; @@ -549,6 +583,34 @@ impl TestClient { ) -> WindowHandle { cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx)) } + + pub async fn add_admin_to_channel( + &self, + user: (&TestClient, &mut TestAppContext), + channel: u64, + cx_self: &mut TestAppContext, + ) { + let (other_client, other_cx) = user; + + self.app_state + .channel_store + .update(cx_self, |channel_store, cx| { + channel_store.invite_member(channel, other_client.user_id().unwrap(), true, cx) + }) + .await + .unwrap(); + + cx_self.foreground().run_until_parked(); + + other_client + .app_state + .channel_store + .update(other_cx, |channels, _| { + channels.respond_to_channel_invite(channel, true) + }) + .await + .unwrap(); + } } impl Drop for TestClient { diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 0a52b9a19f..b6e45471f1 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -30,6 +30,7 @@ channel = { path = "../channel" } clock = { path = "../clock" } collections = { path = "../collections" } context_menu = { path = "../context_menu" } +drag_and_drop = { path = "../drag_and_drop" } editor = { path = "../editor" } feedback = { path = "../feedback" } fuzzy = { path = "../fuzzy" } diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 087f2e1b8e..082702feda 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -166,8 +166,8 @@ impl ChatPanel { let selected_channel_id = this .channel_store .read(cx) - .channel_at_index(selected_ix) - .map(|e| e.1.id); + .channel_at(selected_ix) + .map(|e| e.id); if let Some(selected_channel_id) = selected_channel_id { this.select_channel(selected_channel_id, cx) .detach_and_log_err(cx); @@ -391,7 +391,7 @@ impl ChatPanel { (ItemType::Unselected, true) => &theme.channel_select.hovered_item, }; - let channel = &channel_store.read(cx).channel_at_index(ix).unwrap().1; + let channel = &channel_store.read(cx).channel_at(ix).unwrap(); let channel_id = channel.id; let mut row = Flex::row() diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 6a001cd422..8315f0b6dd 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -5,16 +5,17 @@ use crate::{ channel_view::{self, ChannelView}, chat_panel::ChatPanel, face_pile::FacePile, - CollaborationPanelSettings, + panel_settings, CollaborationPanelSettings, }; use anyhow::Result; use call::ActiveCall; -use channel::{Channel, ChannelEvent, ChannelId, ChannelStore}; +use channel::{Channel, ChannelData, ChannelEvent, ChannelId, ChannelPath, ChannelStore}; use channel_modal::ChannelModal; use client::{proto::PeerId, Client, Contact, User, UserStore}; use contact_finder::ContactFinder; use context_menu::{ContextMenu, ContextMenuItem}; use db::kvp::KEY_VALUE_STORE; +use drag_and_drop::{DragAndDrop, Draggable}; use editor::{Cancel, Editor}; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use futures::StreamExt; @@ -22,9 +23,9 @@ use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, elements::{ - Canvas, ChildView, Component, Empty, Flex, Image, Label, List, ListOffset, ListState, - MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, SafeStylable, - Stack, Svg, + Canvas, ChildView, Component, ContainerStyle, Empty, Flex, Image, Label, List, ListOffset, + ListState, MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, + SafeStylable, Stack, Svg, }, fonts::TextStyle, geometry::{ @@ -40,8 +41,8 @@ use menu::{Confirm, SelectNext, SelectPrev}; use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; use settings::SettingsStore; -use std::{borrow::Cow, mem, sync::Arc}; -use theme::{components::ComponentExt, IconButton}; +use std::{borrow::Cow, hash::Hash, mem, sync::Arc}; +use theme::{components::ComponentExt, IconButton, Interactive}; use util::{iife, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, @@ -49,34 +50,39 @@ use workspace::{ Workspace, }; -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct RemoveChannel { - channel_id: u64, -} - #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct ToggleCollapse { - channel_id: u64, + location: ChannelPath, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct NewChannel { - channel_id: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct InviteMembers { - channel_id: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -struct ManageMembers { - channel_id: u64, + location: ChannelPath, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct RenameChannel { - channel_id: u64, + location: ChannelPath, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct ToggleSelectedIx { + ix: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct RemoveChannel { + channel_id: ChannelId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct InviteMembers { + channel_id: ChannelId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct ManageMembers { + channel_id: ChannelId, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -89,6 +95,41 @@ pub struct JoinChannelCall { pub channel_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct OpenChannelBuffer { + channel_id: ChannelId, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct StartMoveChannelFor { + channel_id: ChannelId, + parent_id: Option, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct StartLinkChannelFor { + channel_id: ChannelId, + parent_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct LinkChannel { + to: ChannelId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct MoveChannel { + to: ChannelId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct UnlinkChannel { + channel_id: ChannelId, + parent_id: ChannelId, +} + +type DraggedChannel = (Channel, Option); + actions!( collab_panel, [ @@ -96,7 +137,10 @@ actions!( Remove, Secondary, CollapseSelectedChannel, - ExpandSelectedChannel + ExpandSelectedChannel, + StartMoveChannel, + StartLinkChannel, + MoveOrLinkToSelected, ] ); @@ -111,12 +155,33 @@ impl_actions!( ToggleCollapse, OpenChannelNotes, JoinChannelCall, + OpenChannelBuffer, + LinkChannel, + StartMoveChannelFor, + StartLinkChannelFor, + MoveChannel, + UnlinkChannel, + ToggleSelectedIx ] ); +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct ChannelMoveClipboard { + channel_id: ChannelId, + parent_id: Option, + intent: ClipboardIntent, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum ClipboardIntent { + Move, + Link, +} + const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel"; pub fn init(cx: &mut AppContext) { + settings::register::(cx); contact_finder::init(cx); channel_modal::init(cx); channel_view::init(cx); @@ -133,20 +198,148 @@ pub fn init(cx: &mut AppContext) { cx.add_action(CollabPanel::manage_members); cx.add_action(CollabPanel::rename_selected_channel); cx.add_action(CollabPanel::rename_channel); - cx.add_action(CollabPanel::toggle_channel_collapsed); + cx.add_action(CollabPanel::toggle_channel_collapsed_action); cx.add_action(CollabPanel::collapse_selected_channel); cx.add_action(CollabPanel::expand_selected_channel); cx.add_action(CollabPanel::open_channel_notes); + + cx.add_action( + |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext| { + if panel.selection.take() != Some(action.ix) { + panel.selection = Some(action.ix) + } + + cx.notify(); + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, + action: &StartMoveChannelFor, + _: &mut ViewContext| { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: action.channel_id, + parent_id: action.parent_id, + intent: ClipboardIntent::Move, + }); + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, + action: &StartLinkChannelFor, + _: &mut ViewContext| { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: action.channel_id, + parent_id: action.parent_id, + intent: ClipboardIntent::Link, + }) + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, _: &StartMoveChannel, _: &mut ViewContext| { + if let Some((_, path)) = panel.selected_channel() { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: path.channel_id(), + parent_id: path.parent_id(), + intent: ClipboardIntent::Move, + }) + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, _: &StartLinkChannel, _: &mut ViewContext| { + if let Some((_, path)) = panel.selected_channel() { + panel.channel_clipboard = Some(ChannelMoveClipboard { + channel_id: path.channel_id(), + parent_id: path.parent_id(), + intent: ClipboardIntent::Link, + }) + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, _: &MoveOrLinkToSelected, cx: &mut ViewContext| { + let clipboard = panel.channel_clipboard.take(); + if let Some(((selected_channel, _), clipboard)) = + panel.selected_channel().zip(clipboard) + { + match clipboard.intent { + ClipboardIntent::Move if clipboard.parent_id.is_some() => { + let parent_id = clipboard.parent_id.unwrap(); + panel.channel_store.update(cx, |channel_store, cx| { + channel_store + .move_channel( + clipboard.channel_id, + parent_id, + selected_channel.id, + cx, + ) + .detach_and_log_err(cx) + }) + } + _ => panel.channel_store.update(cx, |channel_store, cx| { + channel_store + .link_channel(clipboard.channel_id, selected_channel.id, cx) + .detach_and_log_err(cx) + }), + } + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, action: &LinkChannel, cx: &mut ViewContext| { + if let Some(clipboard) = panel.channel_clipboard.take() { + panel.channel_store.update(cx, |channel_store, cx| { + channel_store + .link_channel(clipboard.channel_id, action.to, cx) + .detach_and_log_err(cx) + }) + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, action: &MoveChannel, cx: &mut ViewContext| { + if let Some(clipboard) = panel.channel_clipboard.take() { + panel.channel_store.update(cx, |channel_store, cx| { + if let Some(parent) = clipboard.parent_id { + channel_store + .move_channel(clipboard.channel_id, parent, action.to, cx) + .detach_and_log_err(cx) + } else { + channel_store + .link_channel(clipboard.channel_id, action.to, cx) + .detach_and_log_err(cx) + } + }) + } + }, + ); + + cx.add_action( + |panel: &mut CollabPanel, action: &UnlinkChannel, cx: &mut ViewContext| { + panel.channel_store.update(cx, |channel_store, cx| { + channel_store + .unlink_channel(action.channel_id, action.parent_id, cx) + .detach_and_log_err(cx) + }) + }, + ); } #[derive(Debug)] pub enum ChannelEditingState { Create { - parent_id: Option, + location: Option, pending_name: Option, }, Rename { - channel_id: u64, + location: ChannelPath, pending_name: Option, }, } @@ -164,6 +357,7 @@ pub struct CollabPanel { width: Option, fs: Arc, has_focus: bool, + channel_clipboard: Option, pending_serialization: Task>, context_menu: ViewHandle, filter_editor: ViewHandle, @@ -179,7 +373,8 @@ pub struct CollabPanel { list_state: ListState, subscriptions: Vec, collapsed_sections: Vec
, - collapsed_channels: Vec, + collapsed_channels: Vec, + drag_target_channel: Option, workspace: WeakViewHandle, context_menu_on_selected: bool, } @@ -187,7 +382,7 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { width: Option, - collapsed_channels: Option>, + collapsed_channels: Option>, } #[derive(Debug)] @@ -231,6 +426,7 @@ enum ListEntry { Channel { channel: Arc, depth: usize, + path: ChannelPath, }, ChannelNotes { channel_id: ChannelId, @@ -348,12 +544,18 @@ impl CollabPanel { cx, ) } - ListEntry::Channel { channel, depth } => { + ListEntry::Channel { + channel, + depth, + path, + } => { let channel_row = this.render_channel( &*channel, *depth, + path.to_owned(), &theme.collab_panel, is_selected, + ix, cx, ); @@ -420,6 +622,7 @@ impl CollabPanel { let mut this = Self { width: None, has_focus: false, + channel_clipboard: None, fs: workspace.app_state().fs.clone(), pending_serialization: Task::ready(None), context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)), @@ -438,6 +641,7 @@ impl CollabPanel { workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), context_menu_on_selected: true, + drag_target_channel: None, list_state, }; @@ -507,7 +711,13 @@ impl CollabPanel { .log_err() .flatten() { - Some(serde_json::from_str::(&panel)?) + match serde_json::from_str::(&panel) { + Ok(panel) => Some(panel), + Err(err) => { + log::error!("Failed to deserialize collaboration panel: {}", err); + None + } + } } else { None }; @@ -678,16 +888,13 @@ impl CollabPanel { if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { self.match_candidates.clear(); self.match_candidates - .extend( - channel_store - .channels() - .enumerate() - .map(|(ix, (_, channel))| StringMatchCandidate { - id: ix, - string: channel.name.clone(), - char_bag: channel.name.chars().collect(), - }), - ); + .extend(channel_store.channel_dag_entries().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, @@ -697,28 +904,24 @@ 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 { location: None, .. }) { self.entries.push(ListEntry::ChannelEditor { depth: 0 }); } } let mut collapse_depth = None; for mat in matches { - let (depth, channel) = - channel_store.channel_at_index(mat.candidate_id).unwrap(); + let (channel, path) = channel_store + .channel_dag_entry_at(mat.candidate_id) + .unwrap(); + let depth = path.len() - 1; - if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) { + if collapse_depth.is_none() && self.is_channel_collapsed(path) { collapse_depth = Some(depth); } else if let Some(collapsed_depth) = collapse_depth { if depth > collapsed_depth { continue; } - if self.is_channel_collapsed(channel.id) { + if self.is_channel_collapsed(path) { collapse_depth = Some(depth); } else { collapse_depth = None; @@ -726,25 +929,29 @@ impl CollabPanel { } match &self.channel_editing_state { - Some(ChannelEditingState::Create { parent_id, .. }) - if *parent_id == Some(channel.id) => - { + Some(ChannelEditingState::Create { + location: parent_path, + .. + }) if parent_path.as_ref() == Some(path) => { self.entries.push(ListEntry::Channel { channel: channel.clone(), depth, + path: path.clone(), }); self.entries .push(ListEntry::ChannelEditor { depth: depth + 1 }); } - Some(ChannelEditingState::Rename { channel_id, .. }) - if *channel_id == channel.id => - { + Some(ChannelEditingState::Rename { + location: parent_path, + .. + }) if parent_path == path => { self.entries.push(ListEntry::ChannelEditor { depth }); } _ => { self.entries.push(ListEntry::Channel { channel: channel.clone(), depth, + path: path.clone(), }); } } @@ -1531,7 +1738,7 @@ impl CollabPanel { .constrained() .with_height(theme.collab_panel.row_height) .contained() - .with_style(gpui::elements::ContainerStyle { + .with_style(ContainerStyle { background_color: Some(theme.editor.background), ..*theme.collab_panel.contact_row.default_style() }) @@ -1546,14 +1753,17 @@ impl CollabPanel { &self, channel: &Channel, depth: usize, + path: ChannelPath, theme: &theme::CollabPanel, is_selected: bool, + ix: usize, cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; let has_children = self.channel_store.read(cx).has_children(channel_id); - let disclosed = - has_children.then(|| !self.collapsed_channels.binary_search(&channel_id).is_ok()); + let other_selected = + self.selected_channel().map(|channel| channel.0.id) == Some(channel.id); + let disclosed = has_children.then(|| !self.collapsed_channels.binary_search(&path).is_ok()); let is_active = iife!({ let call_channel = ActiveCall::global(cx) @@ -1569,9 +1779,37 @@ impl CollabPanel { enum ChannelCall {} - MouseEventHandler::new::(channel.id as usize, cx, |state, cx| { + let mut is_dragged_over = false; + if cx + .global::>() + .currently_dragged::(cx.window()) + .is_some() + && self + .drag_target_channel + .as_ref() + .filter(|(_, dragged_path)| path.starts_with(dragged_path)) + .is_some() + { + is_dragged_over = true; + } + + MouseEventHandler::new::(path.unique_id() as usize, cx, |state, cx| { let row_hovered = state.hovered(); + let mut select_state = |interactive: &Interactive| { + if state.clicked() == Some(MouseButton::Left) && interactive.clicked.is_some() { + interactive.clicked.as_ref().unwrap().clone() + } else if state.hovered() || other_selected { + interactive + .hovered + .as_ref() + .unwrap_or(&interactive.default) + .clone() + } else { + interactive.default.clone() + } + }; + Flex::::row() .with_child( Svg::new("icons/hash.svg") @@ -1637,25 +1875,135 @@ impl CollabPanel { ) .align_children_center() .styleable_component() - .disclosable(disclosed, Box::new(ToggleCollapse { channel_id })) - .with_id(channel_id as usize) + .disclosable( + disclosed, + Box::new(ToggleCollapse { + location: path.clone(), + }), + ) + .with_id(path.unique_id() as usize) .with_style(theme.disclosure.clone()) .element() .constrained() .with_height(theme.row_height) .contained() - .with_style(*theme.channel_row.style_for(is_selected || is_active, state)) + .with_style(select_state( + theme + .channel_row + .in_state(is_selected || is_active || is_dragged_over), + )) .with_padding_left( theme.channel_row.default_style().padding.left + theme.channel_indent * depth as f32, ) }) .on_click(MouseButton::Left, move |_, this, cx| { - this.join_channel_chat(channel_id, cx); + if this.drag_target_channel.take().is_none() { + this.join_channel_chat(channel_id, cx); + } }) - .on_click(MouseButton::Right, move |e, this, cx| { - this.deploy_channel_context_menu(Some(e.position), channel_id, cx); + .on_click(MouseButton::Right, { + let path = path.clone(); + move |e, this, cx| { + this.deploy_channel_context_menu(Some(e.position), &path, ix, cx); + } }) + .on_up(MouseButton::Left, move |e, this, cx| { + if let Some((_, dragged_channel)) = cx + .global::>() + .currently_dragged::(cx.window()) + { + if e.modifiers.alt { + this.channel_store.update(cx, |channel_store, cx| { + channel_store + .link_channel(dragged_channel.0.id, channel_id, cx) + .detach_and_log_err(cx) + }) + } else { + this.channel_store.update(cx, |channel_store, cx| { + match dragged_channel.1 { + Some(parent_id) => channel_store.move_channel( + dragged_channel.0.id, + parent_id, + channel_id, + cx, + ), + None => { + channel_store.link_channel(dragged_channel.0.id, channel_id, cx) + } + } + .detach_and_log_err(cx) + }) + } + } + }) + .on_move({ + let channel = channel.clone(); + let path = path.clone(); + move |_, this, cx| { + if let Some((_, _dragged_channel)) = + cx.global::>() + .currently_dragged::(cx.window()) + { + match &this.drag_target_channel { + Some(current_target) + if current_target.0 == channel && current_target.1 == path => + { + return + } + _ => { + this.drag_target_channel = Some((channel.clone(), path.clone())); + cx.notify(); + } + } + } + } + }) + .as_draggable( + (channel.clone(), path.parent_id()), + move |e, (channel, _), cx: &mut ViewContext| { + let theme = &theme::current(cx).collab_panel; + + Flex::::row() + .with_children(e.alt.then(|| { + Svg::new("icons/plus.svg") + .with_color(theme.channel_hash.color) + .constrained() + .with_width(theme.channel_hash.width) + .aligned() + .left() + })) + .with_child( + Svg::new("icons/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.channel_name.text.clone()) + .contained() + .with_style(theme.channel_name.container) + .aligned() + .left(), + ) + .align_children_center() + .contained() + .with_background_color( + theme + .container + .background_color + .unwrap_or(gpui::color::Color::transparent_black()), + ) + .contained() + .with_padding_left( + theme.channel_row.default_style().padding.left + + theme.channel_indent * depth as f32, + ) + .into_any() + }, + ) .with_cursor_style(CursorStyle::PointingHand) .into_any() } @@ -1898,14 +2246,42 @@ impl CollabPanel { .into_any() } + fn has_subchannels(&self, ix: usize) -> bool { + self.entries + .get(ix) + .zip(self.entries.get(ix + 1)) + .map(|entries| match entries { + ( + ListEntry::Channel { + path: this_path, .. + }, + ListEntry::Channel { + path: next_path, .. + }, + ) => next_path.starts_with(this_path), + _ => false, + }) + .unwrap_or(false) + } + fn deploy_channel_context_menu( &mut self, position: Option, - channel_id: u64, + path: &ChannelPath, + ix: usize, cx: &mut ViewContext, ) { self.context_menu_on_selected = position.is_none(); + let channel_name = self.channel_clipboard.as_ref().and_then(|channel| { + let channel_name = self + .channel_store + .read(cx) + .channel_for_id(channel.channel_id) + .map(|channel| channel.name.clone())?; + Some(channel_name) + }); + self.context_menu.update(cx, |context_menu, cx| { context_menu.set_position_mode(if self.context_menu_on_selected { OverlayPositionMode::Local @@ -1913,27 +2289,124 @@ impl CollabPanel { OverlayPositionMode::Window }); - let expand_action_name = if self.is_channel_collapsed(channel_id) { - "Expand Subchannels" + let mut items = Vec::new(); + + let select_action_name = if self.selection == Some(ix) { + "Unselect" } else { - "Collapse Subchannels" + "Select" }; - let mut items = vec![ - ContextMenuItem::action(expand_action_name, ToggleCollapse { channel_id }), - ContextMenuItem::action("Open Notes", OpenChannelNotes { channel_id }), - ]; + items.push(ContextMenuItem::action( + select_action_name, + ToggleSelectedIx { ix }, + )); + + if self.has_subchannels(ix) { + let expand_action_name = if self.is_channel_collapsed(&path) { + "Expand Subchannels" + } else { + "Collapse Subchannels" + }; + items.push(ContextMenuItem::action( + expand_action_name, + ToggleCollapse { + location: path.clone(), + }, + )); + } + + items.push(ContextMenuItem::action( + "Open Notes", + OpenChannelBuffer { + channel_id: path.channel_id(), + }, + )); + + if self.channel_store.read(cx).is_user_admin(path.channel_id()) { + let parent_id = path.parent_id(); - if self.channel_store.read(cx).is_user_admin(channel_id) { items.extend([ ContextMenuItem::Separator, - ContextMenuItem::action("New Subchannel", NewChannel { channel_id }), - ContextMenuItem::action("Rename", RenameChannel { channel_id }), + ContextMenuItem::action( + "New Subchannel", + NewChannel { + location: path.clone(), + }, + ), + ContextMenuItem::action( + "Rename", + RenameChannel { + location: path.clone(), + }, + ), ContextMenuItem::Separator, - ContextMenuItem::action("Invite Members", InviteMembers { channel_id }), - ContextMenuItem::action("Manage Members", ManageMembers { channel_id }), + ]); + + if let Some(parent_id) = parent_id { + items.push(ContextMenuItem::action( + "Unlink from parent", + UnlinkChannel { + channel_id: path.channel_id(), + parent_id, + }, + )); + } + + items.extend([ + ContextMenuItem::action( + "Move this channel", + StartMoveChannelFor { + channel_id: path.channel_id(), + parent_id, + }, + ), + ContextMenuItem::action( + "Link this channel", + StartLinkChannelFor { + channel_id: path.channel_id(), + parent_id, + }, + ), + ]); + + if let Some(channel_name) = channel_name { + items.push(ContextMenuItem::Separator); + items.push(ContextMenuItem::action( + format!("Move '#{}' here", channel_name), + MoveChannel { + to: path.channel_id(), + }, + )); + items.push(ContextMenuItem::action( + format!("Link '#{}' here", channel_name), + LinkChannel { + to: path.channel_id(), + }, + )); + } + + items.extend([ ContextMenuItem::Separator, - ContextMenuItem::action("Delete", RemoveChannel { channel_id }), + ContextMenuItem::action( + "Invite Members", + InviteMembers { + channel_id: path.channel_id(), + }, + ), + ContextMenuItem::action( + "Manage Members", + ManageMembers { + channel_id: path.channel_id(), + }, + ), + ContextMenuItem::Separator, + ContextMenuItem::action( + "Delete", + RemoveChannel { + channel_id: path.channel_id(), + }, + ), ]); } @@ -2059,7 +2532,7 @@ impl CollabPanel { if let Some(editing_state) = &mut self.channel_editing_state { match editing_state { ChannelEditingState::Create { - parent_id, + location, pending_name, .. } => { @@ -2072,13 +2545,17 @@ impl CollabPanel { self.channel_store .update(cx, |channel_store, cx| { - channel_store.create_channel(&channel_name, *parent_id, cx) + channel_store.create_channel( + &channel_name, + location.as_ref().map(|location| location.channel_id()), + cx, + ) }) .detach(); cx.notify(); } ChannelEditingState::Rename { - channel_id, + location, pending_name, } => { if pending_name.is_some() { @@ -2089,7 +2566,7 @@ impl CollabPanel { self.channel_store .update(cx, |channel_store, cx| { - channel_store.rename(*channel_id, &channel_name, cx) + channel_store.rename(location.channel_id(), &channel_name, cx) }) .detach(); cx.notify(); @@ -2116,38 +2593,55 @@ impl CollabPanel { _: &CollapseSelectedChannel, cx: &mut ViewContext, ) { - let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else { + let Some((_, path)) = self + .selected_channel() + .map(|(channel, parent)| (channel.id, parent)) + else { return; }; - if self.is_channel_collapsed(channel_id) { + if self.is_channel_collapsed(&path) { return; } - self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx) + self.toggle_channel_collapsed(&path.clone(), cx); } fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext) { - let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else { + let Some((_, path)) = self + .selected_channel() + .map(|(channel, parent)| (channel.id, parent)) + else { return; }; - if !self.is_channel_collapsed(channel_id) { + if !self.is_channel_collapsed(&path) { return; } - self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx) + self.toggle_channel_collapsed(path.to_owned(), cx) } - fn toggle_channel_collapsed(&mut self, action: &ToggleCollapse, cx: &mut ViewContext) { - let channel_id = action.channel_id; + fn toggle_channel_collapsed_action( + &mut self, + action: &ToggleCollapse, + cx: &mut ViewContext, + ) { + self.toggle_channel_collapsed(&action.location, cx); + } - match self.collapsed_channels.binary_search(&channel_id) { + fn toggle_channel_collapsed<'a>( + &mut self, + path: impl Into>, + cx: &mut ViewContext, + ) { + let path = path.into(); + match self.collapsed_channels.binary_search(&path) { Ok(ix) => { self.collapsed_channels.remove(ix); } Err(ix) => { - self.collapsed_channels.insert(ix, channel_id); + self.collapsed_channels.insert(ix, path.into_owned()); } }; self.serialize(cx); @@ -2156,8 +2650,8 @@ impl CollabPanel { cx.focus_self(); } - fn is_channel_collapsed(&self, channel: ChannelId) -> bool { - self.collapsed_channels.binary_search(&channel).is_ok() + fn is_channel_collapsed(&self, path: &ChannelPath) -> bool { + self.collapsed_channels.binary_search(path).is_ok() } fn leave_call(cx: &mut ViewContext) { @@ -2182,7 +2676,7 @@ impl CollabPanel { fn new_root_channel(&mut self, cx: &mut ViewContext) { self.channel_editing_state = Some(ChannelEditingState::Create { - parent_id: None, + location: None, pending_name: None, }); self.update_entries(false, cx); @@ -2200,9 +2694,9 @@ impl CollabPanel { fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext) { self.collapsed_channels - .retain(|&channel| channel != action.channel_id); + .retain(|channel| *channel != action.location); self.channel_editing_state = Some(ChannelEditingState::Create { - parent_id: Some(action.channel_id), + location: Some(action.location.to_owned()), pending_name: None, }); self.update_entries(false, cx); @@ -2220,16 +2714,16 @@ impl CollabPanel { } fn remove(&mut self, _: &Remove, cx: &mut ViewContext) { - if let Some(channel) = self.selected_channel() { + if let Some((channel, _)) = self.selected_channel() { self.remove_channel(channel.id, cx) } } fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) { - if let Some(channel) = self.selected_channel() { + if let Some((_, parent)) = self.selected_channel() { self.rename_channel( &RenameChannel { - channel_id: channel.id, + location: parent.to_owned(), }, cx, ); @@ -2238,12 +2732,15 @@ impl CollabPanel { fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext) { let channel_store = self.channel_store.read(cx); - if !channel_store.is_user_admin(action.channel_id) { + if !channel_store.is_user_admin(action.location.channel_id()) { return; } - if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() { + if let Some(channel) = channel_store + .channel_for_id(action.location.channel_id()) + .cloned() + { self.channel_editing_state = Some(ChannelEditingState::Rename { - channel_id: action.channel_id, + location: action.location.to_owned(), pending_name: None, }); self.channel_name_editor.update(cx, |editor, cx| { @@ -2263,18 +2760,22 @@ impl CollabPanel { } fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext) { - let Some(channel) = self.selected_channel() else { + let Some((_, path)) = self.selected_channel() else { return; }; - self.deploy_channel_context_menu(None, channel.id, cx); + self.deploy_channel_context_menu(None, &path.to_owned(), self.selection.unwrap(), cx); } - fn selected_channel(&self) -> Option<&Arc> { + fn selected_channel(&self) -> Option<(&Arc, &ChannelPath)> { self.selection .and_then(|ix| self.entries.get(ix)) .and_then(|entry| match entry { - ListEntry::Channel { channel, .. } => Some(channel), + ListEntry::Channel { + channel, + path: parent, + .. + } => Some((channel, parent)), _ => None, }) } @@ -2657,13 +3158,17 @@ impl PartialEq for ListEntry { ListEntry::Channel { channel: channel_1, depth: depth_1, + path: parent_1, } => { if let ListEntry::Channel { channel: channel_2, depth: depth_2, + path: parent_2, } = other { - return channel_1.id == channel_2.id && depth_1 == depth_2; + return channel_1.id == channel_2.id + && depth_1 == depth_2 + && parent_1 == parent_2; } } ListEntry::ChannelNotes { channel_id } => { diff --git a/crates/drag_and_drop/src/drag_and_drop.rs b/crates/drag_and_drop/src/drag_and_drop.rs index 197c9918f5..dc778759fa 100644 --- a/crates/drag_and_drop/src/drag_and_drop.rs +++ b/crates/drag_and_drop/src/drag_and_drop.rs @@ -4,7 +4,7 @@ use collections::HashSet; use gpui::{ elements::{Empty, MouseEventHandler, Overlay}, geometry::{rect::RectF, vector::Vector2F}, - platform::{CursorStyle, MouseButton}, + platform::{CursorStyle, Modifiers, MouseButton}, scene::{MouseDown, MouseDrag}, AnyElement, AnyWindowHandle, Element, View, ViewContext, WeakViewHandle, WindowContext, }; @@ -21,12 +21,13 @@ enum State { region: RectF, }, Dragging { + modifiers: Modifiers, window: AnyWindowHandle, position: Vector2F, region_offset: Vector2F, region: RectF, payload: Rc, - render: Rc, &mut ViewContext) -> AnyElement>, + render: Rc, &mut ViewContext) -> AnyElement>, }, Canceled, } @@ -49,6 +50,7 @@ impl Clone for State { region, }, State::Dragging { + modifiers, window, position, region_offset, @@ -62,6 +64,7 @@ impl Clone for State { region: region.clone(), payload: payload.clone(), render: render.clone(), + modifiers: modifiers.clone(), }, State::Canceled => State::Canceled, } @@ -111,6 +114,27 @@ impl DragAndDrop { }) } + pub fn any_currently_dragged(&self, window: AnyWindowHandle) -> bool { + self.currently_dragged + .as_ref() + .map(|state| { + if let State::Dragging { + window: window_dragged_from, + .. + } = state + { + if &window != window_dragged_from { + return false; + } + + true + } else { + false + } + }) + .unwrap_or(false) + } + pub fn drag_started(event: MouseDown, cx: &mut WindowContext) { cx.update_global(|this: &mut Self, _| { this.currently_dragged = Some(State::Down { @@ -124,7 +148,7 @@ impl DragAndDrop { event: MouseDrag, payload: Rc, cx: &mut WindowContext, - render: Rc) -> AnyElement>, + render: Rc) -> AnyElement>, ) { let window = cx.window(); cx.update_global(|this: &mut Self, cx| { @@ -141,13 +165,14 @@ impl DragAndDrop { }) => { if (event.position - (region.origin() + region_offset)).length() > DEAD_ZONE { this.currently_dragged = Some(State::Dragging { + modifiers: event.modifiers, window, region_offset, region, position: event.position, payload, - render: Rc::new(move |payload, cx| { - render(payload.downcast_ref::().unwrap(), cx) + render: Rc::new(move |modifiers, payload, cx| { + render(modifiers, payload.downcast_ref::().unwrap(), cx) }), }); } else { @@ -160,16 +185,18 @@ impl DragAndDrop { Some(&State::Dragging { region_offset, region, + modifiers, .. }) => { this.currently_dragged = Some(State::Dragging { + modifiers, window, region_offset, region, position: event.position, payload, - render: Rc::new(move |payload, cx| { - render(payload.downcast_ref::().unwrap(), cx) + render: Rc::new(move |modifiers, payload, cx| { + render(modifiers, payload.downcast_ref::().unwrap(), cx) }), }); } @@ -178,6 +205,25 @@ impl DragAndDrop { }); } + pub fn update_modifiers(new_modifiers: Modifiers, cx: &mut ViewContext) -> bool { + let result = cx.update_global(|this: &mut Self, _| match &mut this.currently_dragged { + Some(state) => match state { + State::Dragging { modifiers, .. } => { + *modifiers = new_modifiers; + true + } + _ => false, + }, + None => false, + }); + + if result { + cx.notify(); + } + + result + } + pub fn render(cx: &mut ViewContext) -> Option> { enum DraggedElementHandler {} cx.global::() @@ -188,6 +234,7 @@ impl DragAndDrop { State::Down { .. } => None, State::DeadZone { .. } => None, State::Dragging { + modifiers, window, region_offset, position, @@ -205,7 +252,7 @@ impl DragAndDrop { MouseEventHandler::new::( 0, cx, - |_, cx| render(payload, cx), + |_, cx| render(&modifiers, payload, cx), ) .with_cursor_style(CursorStyle::Arrow) .on_up(MouseButton::Left, |_, _, cx| { @@ -295,7 +342,7 @@ pub trait Draggable { fn as_draggable( self, payload: P, - render: impl 'static + Fn(&P, &mut ViewContext) -> AnyElement, + render: impl 'static + Fn(&Modifiers, &P, &mut ViewContext) -> AnyElement, ) -> Self where Self: Sized; @@ -305,7 +352,7 @@ impl Draggable for MouseEventHandler { fn as_draggable( self, payload: P, - render: impl 'static + Fn(&P, &mut ViewContext) -> AnyElement, + render: impl 'static + Fn(&Modifiers, &P, &mut ViewContext) -> AnyElement, ) -> Self where Self: Sized, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 65415c6690..3263649562 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1513,7 +1513,7 @@ impl ProjectPanel { .as_draggable(entry_id, { let row_container_style = theme.dragged_entry.container; - move |_, cx: &mut ViewContext| { + move |_, _, cx: &mut ViewContext| { let theme = theme::current(cx).clone(); Self::render_entry_visual_element( &details, diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 855588a2d8..c2bb9e9cef 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -135,17 +135,18 @@ message Envelope { RefreshInlayHints refresh_inlay_hints = 118; CreateChannel create_channel = 119; - ChannelResponse channel_response = 120; + 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; JoinChannel join_channel = 125; - RemoveChannel remove_channel = 126; + DeleteChannel delete_channel = 126; GetChannelMembers get_channel_members = 127; GetChannelMembersResponse get_channel_members_response = 128; SetChannelMemberAdmin set_channel_member_admin = 129; RenameChannel rename_channel = 130; + RenameChannelResponse rename_channel_response = 154; JoinChannelBuffer join_channel_buffer = 131; JoinChannelBufferResponse join_channel_buffer_response = 132; @@ -165,7 +166,11 @@ message Envelope { ChannelMessageSent channel_message_sent = 147; GetChannelMessages get_channel_messages = 148; GetChannelMessagesResponse get_channel_messages_response = 149; - RemoveChannelMessage remove_channel_message = 150; // Current max + RemoveChannelMessage remove_channel_message = 150; + + LinkChannel link_channel = 151; + UnlinkChannel unlink_channel = 152; + MoveChannel move_channel = 153; // Current max: 154 } } @@ -955,11 +960,18 @@ message LspDiskBasedDiagnosticsUpdated {} message UpdateChannels { repeated Channel channels = 1; - repeated uint64 remove_channels = 2; - repeated Channel channel_invitations = 3; - repeated uint64 remove_channel_invitations = 4; - repeated ChannelParticipants channel_participants = 5; - repeated ChannelPermission channel_permissions = 6; + repeated ChannelEdge insert_edge = 2; + repeated ChannelEdge delete_edge = 3; + repeated uint64 delete_channels = 4; + repeated Channel channel_invitations = 5; + repeated uint64 remove_channel_invitations = 6; + repeated ChannelParticipants channel_participants = 7; + repeated ChannelPermission channel_permissions = 8; +} + +message ChannelEdge { + uint64 channel_id = 1; + uint64 parent_id = 2; } message ChannelPermission { @@ -976,7 +988,7 @@ message JoinChannel { uint64 channel_id = 1; } -message RemoveChannel { +message DeleteChannel { uint64 channel_id = 1; } @@ -1005,8 +1017,9 @@ message CreateChannel { optional uint64 parent_id = 2; } -message ChannelResponse { +message CreateChannelResponse { Channel channel = 1; + optional uint64 parent_id = 2; } message InviteChannelMember { @@ -1031,6 +1044,10 @@ message RenameChannel { string name = 2; } +message RenameChannelResponse { + Channel channel = 1; +} + message JoinChannelChat { uint64 channel_id = 1; } @@ -1074,6 +1091,22 @@ message GetChannelMessagesResponse { bool done = 2; } +message LinkChannel { + uint64 channel_id = 1; + uint64 to = 2; +} + +message UnlinkChannel { + uint64 channel_id = 1; + uint64 from = 2; +} + +message MoveChannel { + uint64 channel_id = 1; + uint64 from = 2; + uint64 to = 3; +} + message JoinChannelBuffer { uint64 channel_id = 1; } @@ -1486,7 +1519,6 @@ message Nonce { message Channel { uint64 id = 1; string name = 2; - optional uint64 parent_id = 3; } message Contact { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 240daed1b2..44a7df3b74 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -146,7 +146,7 @@ messages!( (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), - (ChannelResponse, Foreground), + (CreateChannelResponse, Foreground), (ChannelMessageSent, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), @@ -229,6 +229,7 @@ messages!( (RoomUpdated, Foreground), (SaveBuffer, Foreground), (RenameChannel, Foreground), + (RenameChannelResponse, Foreground), (SetChannelMemberAdmin, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), @@ -246,7 +247,10 @@ messages!( (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), (UpdateContacts, Foreground), - (RemoveChannel, Foreground), + (DeleteChannel, Foreground), + (MoveChannel, Foreground), + (LinkChannel, Foreground), + (UnlinkChannel, Foreground), (UpdateChannels, Foreground), (UpdateDiagnosticSummary, Foreground), (UpdateFollowers, Foreground), @@ -282,7 +286,7 @@ request_messages!( (CopyProjectEntry, ProjectEntryResponse), (CreateProjectEntry, ProjectEntryResponse), (CreateRoom, CreateRoomResponse), - (CreateChannel, ChannelResponse), + (CreateChannel, CreateChannelResponse), (DeclineCall, Ack), (DeleteProjectEntry, ProjectEntryResponse), (ExpandProjectEntry, ExpandProjectEntryResponse), @@ -327,10 +331,13 @@ request_messages!( (GetChannelMessages, GetChannelMessagesResponse), (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), - (RemoveChannel, Ack), (RemoveChannelMessage, Ack), + (DeleteChannel, Ack), (RenameProjectEntry, ProjectEntryResponse), - (RenameChannel, ChannelResponse), + (RenameChannel, RenameChannelResponse), + (LinkChannel, Ack), + (UnlinkChannel, Ack), + (MoveChannel, Ack), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), (ShareProject, ShareProjectResponse), diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 1b48275ee9..a3e6a547dd 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1383,7 +1383,7 @@ impl Pane { let theme = theme::current(cx).clone(); let detail = detail.clone(); - move |dragged_item: &DraggedItem, cx: &mut ViewContext| { + move |_, dragged_item: &DraggedItem, cx: &mut ViewContext| { let tab_style = &theme.workspace.tab_bar.dragged_tab; Self::render_dragged_tab( &dragged_item.handle, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 999d968575..feab53d094 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -33,8 +33,8 @@ use gpui::{ }, impl_actions, platform::{ - CursorStyle, MouseButton, PathPromptOptions, Platform, PromptLevel, WindowBounds, - WindowOptions, + CursorStyle, ModifiersChangedEvent, MouseButton, PathPromptOptions, Platform, PromptLevel, + WindowBounds, WindowOptions, }, AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, @@ -3815,6 +3815,10 @@ impl View for Workspace { cx.focus(&self.active_pane); } } + + fn modifiers_changed(&mut self, e: &ModifiersChangedEvent, cx: &mut ViewContext) -> bool { + DragAndDrop::::update_modifiers(e.modifiers, cx) + } } impl ViewId {