Add UI/UX for moving channels (#2976)

Mikayla Maki created

TODO:

- [x] Add drag and drop
- [x] Polish up in-flight decisions.
- [x] Fix chat panel panic
- [x] Add nice hover effect highlighting the matching ones
- [x] Fix and test keyboard

Release Notes:

- N/A

Change summary

Cargo.lock                                             |   3 
assets/keymaps/default.json                            |   8 
crates/channel/Cargo.toml                              |   1 
crates/channel/src/channel.rs                          |   4 
crates/channel/src/channel_store.rs                    | 267 ++-
crates/channel/src/channel_store/channel_index.rs      | 162 ++
crates/channel/src/channel_store_tests.rs              |  34 
crates/collab/Cargo.toml                               |   3 
crates/collab/src/db.rs                                |  12 
crates/collab/src/db/queries/channels.rs               | 473 ++++++
crates/collab/src/db/tests.rs                          |  26 
crates/collab/src/db/tests/channel_tests.rs            | 817 ++++++++++++
crates/collab/src/db/tests/db_tests.rs                 | 452 ------
crates/collab/src/rpc.rs                               | 200 ++
crates/collab/src/tests/channel_buffer_tests.rs        |  20 
crates/collab/src/tests/channel_message_tests.rs       |  15 
crates/collab/src/tests/channel_tests.rs               | 297 ++++
crates/collab/src/tests/random_channel_buffer_tests.rs |   4 
crates/collab/src/tests/test_server.rs                 |  64 
crates/collab_ui/Cargo.toml                            |   1 
crates/collab_ui/src/chat_panel.rs                     |   6 
crates/collab_ui/src/collab_panel.rs                   | 678 ++++++++-
crates/drag_and_drop/src/drag_and_drop.rs              |  67 
crates/project_panel/src/project_panel.rs              |   2 
crates/rpc/proto/zed.proto                             |  54 
crates/rpc/src/proto.rs                                |  17 
crates/workspace/src/pane.rs                           |   2 
crates/workspace/src/workspace.rs                      |   8 
28 files changed, 2,921 insertions(+), 776 deletions(-)

Detailed changes

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",

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": {

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

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;

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<ChannelId, Arc<Channel>>,
-    channel_paths: Vec<Vec<ChannelId>>,
+    channel_index: ChannelIndex,
     channel_invitations: Vec<Arc<Channel>>,
     channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
     channels_with_admin_privileges: HashSet<ChannelId>,
@@ -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<User>,
     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<usize> {
-        self.channel_paths
-            .iter()
-            .position(|path| path.ends_with(&[channel_id]))
+        self.channel_index
+            .by_id()
+            .keys()
+            .position(|id| *id == channel_id)
+    }
+
+    /// Returns an iterator over all unique channels
+    pub fn channels(&self) -> impl '_ + Iterator<Item = &Arc<Channel>> {
+        self.channel_index.by_id().values()
     }
 
-    pub fn channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
-        self.channel_paths.iter().map(move |path| {
+    /// Iterate over all entries in the channel DAG
+    pub fn channel_dag_entries(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
+        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<Channel>)> {
-        let path = self.channel_paths.get(ix)?;
+    pub fn channel_dag_entry_at(&self, ix: usize) -> Option<(&Arc<Channel>, &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<Channel>> {
+        self.channel_index.by_id().values().nth(ix)
     }
 
     pub fn channel_invitations(&self) -> &[Arc<Channel>] {
@@ -155,7 +190,7 @@ impl ChannelStore {
     }
 
     pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc<Channel>> {
-        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<Self>,
+    ) -> Task<Result<()>> {
+        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<Self>,
+    ) -> Task<Result<()>> {
+        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<Self>,
+    ) -> Task<Result<()>> {
+        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<Output = Result<()>> {
         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>) {
-        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();
+        let channels_changed = !payload.channels.is_empty()
+            || !payload.delete_channels.is_empty()
+            || !payload.insert_edge.is_empty()
+            || !payload.delete_edge.is_empty();
+
         if channels_changed {
-            if !payload.remove_channels.is_empty() {
-                self.channels_by_id
-                    .retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
+            if !payload.delete_channels.is_empty() {
+                self.channel_index.delete_channels(&payload.delete_channels);
                 self.channel_participants
-                    .retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
+                    .retain(|channel_id, _| !payload.delete_channels.contains(channel_id));
                 self.channels_with_admin_privileges
-                    .retain(|channel_id| !payload.remove_channels.contains(channel_id));
+                    .retain(|channel_id| !payload.delete_channels.contains(channel_id));
 
-                for channel_id in &payload.remove_channels {
+                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(())
         }))
     }
+}
+
+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<ChannelId> {
+        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<ChannelPath> 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)
+    }
+}
 
-    fn channel_path_sorting_key<'a>(
-        path: &'a [ChannelId],
-        channels_by_id: &'a HashMap<ChannelId, Arc<Channel>>,
-    ) -> impl 'a + Iterator<Item = Option<&'a str>> {
-        path.iter()
-            .map(|id| Some(channels_by_id.get(id)?.name.as_str()))
+impl Default for ChannelPath {
+    fn default() -> Self {
+        ChannelPath(Arc::from([]))
     }
 }

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<ChannelPath>,
+    channels_by_id: BTreeMap<ChannelId, Arc<Channel>>,
+}
+
+impl ChannelIndex {
+    pub fn by_id(&self) -> &BTreeMap<ChannelId, Arc<Channel>> {
+        &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<ChannelPath>,
+    channels_by_id: &'a mut BTreeMap<ChannelId, Arc<Channel>>,
+}
+
+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<ChannelId, Arc<Channel>>,
+) -> impl 'a + Iterator<Item = Option<&'a str>> {
+    path.iter()
+        .map(|id| Some(channels_by_id.get(id)?.name.as_str()))
+}

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::<proto::JoinChannelChat>().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,

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

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<String>,
 }
 
-#[derive(FromQueryResult, Debug, PartialEq)]
+#[derive(FromQueryResult, Debug, PartialEq, Eq, Hash)]
 pub struct Channel {
     pub id: ChannelId,
     pub name: String,
-    pub parent_id: Option<ChannelId>,
 }
 
 #[derive(Debug, PartialEq)]
 pub struct ChannelsForUser {
-    pub channels: Vec<Channel>,
+    pub channels: ChannelGraph,
     pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
     pub channels_with_admin_privileges: HashSet<ChannelId>,
 }

crates/collab/src/db/queries/channels.rs 🔗

@@ -1,5 +1,10 @@
+use rpc::proto::ChannelEdge;
+use smallvec::SmallVec;
+
 use super::*;
 
+type ChannelDescendants = HashMap<ChannelId, SmallSet<ChannelId>>;
+
 impl Database {
     #[cfg(test)]
     pub async fn all_channels(&self) -> Result<Vec<(ChannelId, String)>> {
@@ -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<ChannelGraph> {
+        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<ChannelsForUser> {
         self.transaction(|tx| async move {
             let tx = tx;
@@ -332,61 +391,80 @@ impl Database {
                 .all(&*tx)
                 .await?;
 
-            let parents_by_child_id = self
-                .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
+            self.get_user_channels(channel_memberships, &tx).await
+        })
+        .await
+    }
+
+    pub async fn get_channel_for_user(
+        &self,
+        channel_id: ChannelId,
+        user_id: UserId,
+    ) -> Result<ChannelsForUser> {
+        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?;
 
-            let channels_with_admin_privileges = channel_memberships
-                .iter()
-                .filter_map(|membership| membership.admin.then_some(membership.channel_id))
-                .collect();
+            self.get_user_channels(channel_membership, &tx).await
+        })
+        .await
+    }
 
-            let mut channels = Vec::with_capacity(parents_by_child_id.len());
-            {
-                let mut rows = channel::Entity::find()
-                    .filter(channel::Column::Id.is_in(parents_by_child_id.keys().copied()))
-                    .stream(&*tx)
-                    .await?;
-                while let Some(row) = rows.next().await {
-                    let row = row?;
-                    channels.push(Channel {
-                        id: row.id,
-                        name: row.name,
-                        parent_id: parents_by_child_id.get(&row.id).copied().flatten(),
-                    });
-                }
-            }
+    pub async fn get_user_channels(
+        &self,
+        channel_memberships: Vec<channel_member::Model>,
+        tx: &DatabaseTransaction,
+    ) -> Result<ChannelsForUser> {
+        let parents_by_child_id = self
+            .get_channel_descendants(channel_memberships.iter().map(|m| m.channel_id), &*tx)
+            .await?;
 
-            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-            enum QueryUserIdsAndChannelIds {
-                ChannelId,
-                UserId,
-            }
+        let channels_with_admin_privileges = channel_memberships
+            .iter()
+            .filter_map(|membership| membership.admin.then_some(membership.channel_id))
+            .collect();
 
-            let mut channel_participants: HashMap<ChannelId, Vec<UserId>> = 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)
-                }
+        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<ChannelId, Vec<UserId>> = 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,
-                channel_participants,
-                channels_with_admin_privileges,
-            })
+        Ok(ChannelsForUser {
+            channels: graph,
+            channel_participants,
+            channels_with_admin_privileges,
         })
-        .await
     }
 
     pub async fn get_channel_members(&self, id: ChannelId) -> Result<Vec<UserId>> {
@@ -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<Vec<ChannelId>> {
         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<Item = ChannelId>,
         tx: &DatabaseTransaction,
-    ) -> Result<HashMap<ChannelId, Option<ChannelId>>> {
+    ) -> Result<ChannelDescendants> {
         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<ChannelGraph> {
+        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<ChannelGraph> {
+        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<ChannelGraph> {
+        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<Channel>,
+    pub edges: Vec<ChannelEdge>,
+}
+
+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::<HashSet<_>>();
+        let other_channels_set = other.channels.iter().collect::<HashSet<_>>();
+        let edges_set = self
+            .edges
+            .iter()
+            .map(|edge| (edge.channel_id, edge.parent_id))
+            .collect::<HashSet<_>>();
+        let other_edges_set = other
+            .edges
+            .iter()
+            .map(|edge| (edge.channel_id, edge.parent_id))
+            .collect::<HashSet<_>>();
+
+        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<T>(SmallVec<[T; 1]>);
+
+impl<T> Deref for SmallSet<T> {
+    type Target = [T];
+
+    fn deref(&self) -> &Self::Target {
+        self.0.deref()
+    }
+}
+
+impl<T> Default for SmallSet<T> {
+    fn default() -> Self {
+        Self(SmallVec::new())
+    }
+}
+
+impl<T> SmallSet<T> {
+    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();
+    }
+}

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
+}

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<Database>) {
+    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<Database>) {
+    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<Database>) {
+    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::<Vec<_>>();
+
+    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::<Vec<_>>();
+
+    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<Database>) {
+    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<Database>) {
+    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<ChannelId>)]) {
+    let mut actual_map: HashMap<ChannelId, HashSet<ChannelId>> = 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<ChannelId, HashSet<ChannelId>> = 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)
+}

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<Database>) {
-    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<Database>) {
-    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<Database>) {
-    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::<Vec<_>>();
-
-    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::<Vec<_>>();
-
-    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<Database>) {
-    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<Background> {
     Deterministic::new(0).build_background()
 }

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 Some(parent_id) = parent_id else {
+        return Ok(());
+    };
 
-    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 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<proto::RemoveChannel>,
+async fn delete_channel(
+    request: proto::DeleteChannel,
+    response: Response<proto::DeleteChannel>,
     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<proto::LinkChannel>,
+    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<proto::UnlinkChannel>,
+    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<proto::MoveChannel>,
+    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<proto::GetChannelMembers>,
@@ -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,
         });
     }
 

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<Deterministic>, 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)],
         )

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)],
         )

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::<Vec<_>>().is_empty())
+        assert!(channels
+            .channel_dag_entries()
+            .collect::<Vec<_>>()
+            .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<Deterministic>, 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<Deterministic>,
+    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::<Vec<_>>()
     });
-    assert_eq!(actual, expected_channels);
+    pretty_assertions::assert_eq!(actual, expected_channels);
+}
+
+#[track_caller]
+fn assert_channels_list_shape(
+    channel_store: &ModelHandle<ChannelStore>,
+    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::<Vec<_>>()
+    });
+    pretty_assertions::assert_eq!(dbg!(actual), expected_channels);
 }

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

crates/collab/src/tests/test_server.rs 🔗

@@ -288,6 +288,7 @@ impl TestServer {
     pub async fn make_channel(
         &self,
         channel: &str,
+        parent: Option<u64>,
         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<u64> {
+        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<Workspace> {
         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 {

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" }

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()

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},
@@ -50,33 +51,38 @@ use workspace::{
 };
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct RemoveChannel {
-    channel_id: u64,
+struct ToggleCollapse {
+    location: ChannelPath,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct ToggleCollapse {
-    channel_id: u64,
+struct NewChannel {
+    location: ChannelPath,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct NewChannel {
-    channel_id: u64,
+struct RenameChannel {
+    location: ChannelPath,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct InviteMembers {
-    channel_id: u64,
+struct ToggleSelectedIx {
+    ix: usize,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct ManageMembers {
-    channel_id: u64,
+struct RemoveChannel {
+    channel_id: ChannelId,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
-struct RenameChannel {
-    channel_id: u64,
+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<ChannelId>,
+}
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct StartLinkChannelFor {
+    channel_id: ChannelId,
+    parent_id: Option<ChannelId>,
+}
+
+#[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<ChannelId>);
+
 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<ChannelId>,
+    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::<panel_settings::CollaborationPanelSettings>(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<CollabPanel>| {
+            if panel.selection.take() != Some(action.ix) {
+                panel.selection = Some(action.ix)
+            }
+
+            cx.notify();
+        },
+    );
+
+    cx.add_action(
+        |panel: &mut CollabPanel,
+         action: &StartMoveChannelFor,
+         _: &mut ViewContext<CollabPanel>| {
+            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<CollabPanel>| {
+            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<CollabPanel>| {
+            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<CollabPanel>| {
+            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<CollabPanel>| {
+            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<CollabPanel>| {
+            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<CollabPanel>| {
+            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<CollabPanel>| {
+            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<u64>,
+        location: Option<ChannelPath>,
         pending_name: Option<String>,
     },
     Rename {
-        channel_id: u64,
+        location: ChannelPath,
         pending_name: Option<String>,
     },
 }
@@ -164,6 +357,7 @@ pub struct CollabPanel {
     width: Option<f32>,
     fs: Arc<dyn Fs>,
     has_focus: bool,
+    channel_clipboard: Option<ChannelMoveClipboard>,
     pending_serialization: Task<Option<()>>,
     context_menu: ViewHandle<ContextMenu>,
     filter_editor: ViewHandle<Editor>,
@@ -179,7 +373,8 @@ pub struct CollabPanel {
     list_state: ListState<Self>,
     subscriptions: Vec<Subscription>,
     collapsed_sections: Vec<Section>,
-    collapsed_channels: Vec<ChannelId>,
+    collapsed_channels: Vec<ChannelPath>,
+    drag_target_channel: Option<ChannelData>,
     workspace: WeakViewHandle<Workspace>,
     context_menu_on_selected: bool,
 }
@@ -187,7 +382,7 @@ pub struct CollabPanel {
 #[derive(Serialize, Deserialize)]
 struct SerializedCollabPanel {
     width: Option<f32>,
-    collapsed_channels: Option<Vec<ChannelId>>,
+    collapsed_channels: Option<Vec<ChannelPath>>,
 }
 
 #[derive(Debug)]
@@ -231,6 +426,7 @@ enum ListEntry {
     Channel {
         channel: Arc<Channel>,
         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::<SerializedCollabPanel>(&panel)?)
+                match serde_json::from_str::<SerializedCollabPanel>(&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<Self>,
     ) -> AnyElement<Self> {
         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, _>(channel.id as usize, cx, |state, cx| {
+        let mut is_dragged_over = false;
+        if cx
+            .global::<DragAndDrop<Workspace>>()
+            .currently_dragged::<DraggedChannel>(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::<Channel, _>(path.unique_id() as usize, cx, |state, cx| {
             let row_hovered = state.hovered();
 
+            let mut select_state = |interactive: &Interactive<ContainerStyle>| {
+                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::<Self>::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::<DragAndDrop<Workspace>>()
+                .currently_dragged::<DraggedChannel>(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::<DragAndDrop<Workspace>>()
+                        .currently_dragged::<DraggedChannel>(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<Workspace>| {
+                let theme = &theme::current(cx).collab_panel;
+
+                Flex::<Workspace>::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<Vector2F>,
-        channel_id: u64,
+        path: &ChannelPath,
+        ix: usize,
         cx: &mut ViewContext<Self>,
     ) {
         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,
+                ]);
+
+                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("Invite Members", InviteMembers { channel_id }),
-                    ContextMenuItem::action("Manage Members", ManageMembers { 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 }),
+                    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<Self>,
     ) {
-        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<Self>) {
-        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<Self>) {
-        let channel_id = action.channel_id;
+    fn toggle_channel_collapsed_action(
+        &mut self,
+        action: &ToggleCollapse,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<Cow<'a, ChannelPath>>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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);

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<V> {
         region: RectF,
     },
     Dragging {
+        modifiers: Modifiers,
         window: AnyWindowHandle,
         position: Vector2F,
         region_offset: Vector2F,
         region: RectF,
         payload: Rc<dyn Any + 'static>,
-        render: Rc<dyn Fn(Rc<dyn Any>, &mut ViewContext<V>) -> AnyElement<V>>,
+        render: Rc<dyn Fn(&Modifiers, Rc<dyn Any>, &mut ViewContext<V>) -> AnyElement<V>>,
     },
     Canceled,
 }
@@ -49,6 +50,7 @@ impl<V> Clone for State<V> {
                 region,
             },
             State::Dragging {
+                modifiers,
                 window,
                 position,
                 region_offset,
@@ -62,6 +64,7 @@ impl<V> Clone for State<V> {
                 region: region.clone(),
                 payload: payload.clone(),
                 render: render.clone(),
+                modifiers: modifiers.clone(),
             },
             State::Canceled => State::Canceled,
         }
@@ -111,6 +114,27 @@ impl<V: 'static> DragAndDrop<V> {
         })
     }
 
+    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<V: 'static> DragAndDrop<V> {
         event: MouseDrag,
         payload: Rc<T>,
         cx: &mut WindowContext,
-        render: Rc<impl 'static + Fn(&T, &mut ViewContext<V>) -> AnyElement<V>>,
+        render: Rc<impl 'static + Fn(&Modifiers, &T, &mut ViewContext<V>) -> AnyElement<V>>,
     ) {
         let window = cx.window();
         cx.update_global(|this: &mut Self, cx| {
@@ -141,13 +165,14 @@ impl<V: 'static> DragAndDrop<V> {
                 }) => {
                     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::<T>().unwrap(), cx)
+                            render: Rc::new(move |modifiers, payload, cx| {
+                                render(modifiers, payload.downcast_ref::<T>().unwrap(), cx)
                             }),
                         });
                     } else {
@@ -160,16 +185,18 @@ impl<V: 'static> DragAndDrop<V> {
                 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::<T>().unwrap(), cx)
+                        render: Rc::new(move |modifiers, payload, cx| {
+                            render(modifiers, payload.downcast_ref::<T>().unwrap(), cx)
                         }),
                     });
                 }
@@ -178,6 +205,25 @@ impl<V: 'static> DragAndDrop<V> {
         });
     }
 
+    pub fn update_modifiers(new_modifiers: Modifiers, cx: &mut ViewContext<V>) -> 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<V>) -> Option<AnyElement<V>> {
         enum DraggedElementHandler {}
         cx.global::<Self>()
@@ -188,6 +234,7 @@ impl<V: 'static> DragAndDrop<V> {
                     State::Down { .. } => None,
                     State::DeadZone { .. } => None,
                     State::Dragging {
+                        modifiers,
                         window,
                         region_offset,
                         position,
@@ -205,7 +252,7 @@ impl<V: 'static> DragAndDrop<V> {
                                 MouseEventHandler::new::<DraggedElementHandler, _>(
                                     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<V> {
     fn as_draggable<D: View, P: Any>(
         self,
         payload: P,
-        render: impl 'static + Fn(&P, &mut ViewContext<D>) -> AnyElement<D>,
+        render: impl 'static + Fn(&Modifiers, &P, &mut ViewContext<D>) -> AnyElement<D>,
     ) -> Self
     where
         Self: Sized;
@@ -305,7 +352,7 @@ impl<V: 'static> Draggable<V> for MouseEventHandler<V> {
     fn as_draggable<D: View, P: Any>(
         self,
         payload: P,
-        render: impl 'static + Fn(&P, &mut ViewContext<D>) -> AnyElement<D>,
+        render: impl 'static + Fn(&Modifiers, &P, &mut ViewContext<D>) -> AnyElement<D>,
     ) -> Self
     where
         Self: Sized,

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<Workspace>| {
+            move |_, _, cx: &mut ViewContext<Workspace>| {
                 let theme = theme::current(cx).clone();
                 Self::render_entry_visual_element(
                     &details,

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 {

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),

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<Workspace>| {
+                        move |_, dragged_item: &DraggedItem, cx: &mut ViewContext<Workspace>| {
                             let tab_style = &theme.workspace.tab_bar.dragged_tab;
                             Self::render_dragged_tab(
                                 &dragged_item.handle,

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<Self>) -> bool {
+        DragAndDrop::<Workspace>::update_modifiers(e.modifiers, cx)
+    }
 }
 
 impl ViewId {