Set up UI to allow dragging a channel to the root

Max Brunsfeld created

Change summary

crates/channel/src/channel_store.rs         |   2 
crates/collab/src/db/queries/channels.rs    |  24 ++--
crates/collab/src/db/tests/channel_tests.rs |   2 
crates/collab/src/rpc.rs                    |   2 
crates/collab/src/tests/channel_tests.rs    |   6 
crates/collab_ui/src/collab_panel.rs        | 111 +++++++++++++++-------
crates/rpc/proto/zed.proto                  |   2 
crates/theme/src/theme.rs                   |   1 
styles/src/style_tree/collab_panel.ts       |  10 +
styles/src/style_tree/search.ts             |   5 
10 files changed, 107 insertions(+), 58 deletions(-)

Detailed changes

crates/channel/src/channel_store.rs 🔗

@@ -501,7 +501,7 @@ impl ChannelStore {
     pub fn move_channel(
         &mut self,
         channel_id: ChannelId,
-        to: ChannelId,
+        to: Option<ChannelId>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<()>> {
         let client = self.client.clone();

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

@@ -1205,37 +1205,37 @@ impl Database {
     pub async fn move_channel(
         &self,
         channel_id: ChannelId,
-        new_parent_id: ChannelId,
+        new_parent_id: Option<ChannelId>,
         admin_id: UserId,
     ) -> Result<Option<MoveChannelResult>> {
-        // check you're an admin of source and target (and maybe current channel)
-        // change parent_path on current channel
-        // change parent_path on all children
-
         self.transaction(|tx| async move {
+            let Some(new_parent_id) = new_parent_id else {
+                return Err(anyhow!("not supported"))?;
+            };
+
             let new_parent = self.get_channel_internal(new_parent_id, &*tx).await?;
+            self.check_user_is_channel_admin(&new_parent, admin_id, &*tx)
+                .await?;
             let channel = self.get_channel_internal(channel_id, &*tx).await?;
 
             self.check_user_is_channel_admin(&channel, admin_id, &*tx)
                 .await?;
-            self.check_user_is_channel_admin(&new_parent, admin_id, &*tx)
-                .await?;
 
             let previous_participants = self
                 .get_channel_participant_details_internal(&channel, &*tx)
                 .await?;
 
             let old_path = format!("{}{}/", channel.parent_path, channel.id);
-            let new_parent_path = format!("{}{}/", new_parent.parent_path, new_parent_id);
+            let new_parent_path = format!("{}{}/", new_parent.parent_path, new_parent.id);
             let new_path = format!("{}{}/", new_parent_path, channel.id);
 
             if old_path == new_path {
                 return Ok(None);
             }
 
-            let mut channel = channel.into_active_model();
-            channel.parent_path = ActiveValue::Set(new_parent_path);
-            channel.save(&*tx).await?;
+            let mut model = channel.into_active_model();
+            model.parent_path = ActiveValue::Set(new_parent_path);
+            model.update(&*tx).await?;
 
             let descendent_ids =
                 ChannelId::find_by_statement::<QueryIds>(Statement::from_sql_and_values(
@@ -1250,7 +1250,7 @@ impl Database {
                 .all(&*tx)
                 .await?;
 
-            let participants_to_update: HashMap<UserId, ChannelsForUser> = self
+            let participants_to_update: HashMap<_, _> = self
                 .participants_to_notify_for_channel_change(&new_parent, &*tx)
                 .await?
                 .into_iter()

crates/collab/src/db/tests/channel_tests.rs 🔗

@@ -424,7 +424,7 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
 
     // Move to same parent should be a no-op
     assert!(db
-        .move_channel(projects_id, zed_id, user_id)
+        .move_channel(projects_id, Some(zed_id), user_id)
         .await
         .unwrap()
         .is_none());

crates/collab/src/rpc.rs 🔗

@@ -2476,7 +2476,7 @@ async fn move_channel(
     session: Session,
 ) -> Result<()> {
     let channel_id = ChannelId::from_proto(request.channel_id);
-    let to = ChannelId::from_proto(request.to);
+    let to = request.to.map(ChannelId::from_proto);
 
     let result = session
         .db()

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

@@ -1016,7 +1016,7 @@ async fn test_channel_link_notifications(
     client_a
         .channel_store()
         .update(cx_a, |channel_store, cx| {
-            channel_store.move_channel(vim_channel, active_channel, cx)
+            channel_store.move_channel(vim_channel, Some(active_channel), cx)
         })
         .await
         .unwrap();
@@ -1051,7 +1051,7 @@ async fn test_channel_link_notifications(
     client_a
         .channel_store()
         .update(cx_a, |channel_store, cx| {
-            channel_store.move_channel(helix_channel, vim_channel, cx)
+            channel_store.move_channel(helix_channel, Some(vim_channel), cx)
         })
         .await
         .unwrap();
@@ -1424,7 +1424,7 @@ async fn test_channel_moving(
     client_a
         .channel_store()
         .update(cx_a, |channel_store, cx| {
-            channel_store.move_channel(channel_d_id, channel_b_id, cx)
+            channel_store.move_channel(channel_d_id, Some(channel_b_id), cx)
         })
         .await
         .unwrap();

crates/collab_ui/src/collab_panel.rs 🔗

@@ -226,7 +226,7 @@ pub fn init(cx: &mut AppContext) {
             panel
                 .channel_store
                 .update(cx, |channel_store, cx| {
-                    channel_store.move_channel(clipboard.channel_id, selected_channel.id, cx)
+                    channel_store.move_channel(clipboard.channel_id, Some(selected_channel.id), cx)
                 })
                 .detach_and_log_err(cx)
         },
@@ -237,7 +237,7 @@ pub fn init(cx: &mut AppContext) {
             if let Some(clipboard) = panel.channel_clipboard.take() {
                 panel.channel_store.update(cx, |channel_store, cx| {
                     channel_store
-                        .move_channel(clipboard.channel_id, action.to, cx)
+                        .move_channel(clipboard.channel_id, Some(action.to), cx)
                         .detach_and_log_err(cx)
                 })
             }
@@ -287,11 +287,18 @@ pub struct CollabPanel {
     subscriptions: Vec<Subscription>,
     collapsed_sections: Vec<Section>,
     collapsed_channels: Vec<ChannelId>,
-    drag_target_channel: Option<ChannelId>,
+    drag_target_channel: ChannelDragTarget,
     workspace: WeakViewHandle<Workspace>,
     context_menu_on_selected: bool,
 }
 
+#[derive(PartialEq, Eq)]
+enum ChannelDragTarget {
+    None,
+    Root,
+    Channel(ChannelId),
+}
+
 #[derive(Serialize, Deserialize)]
 struct SerializedCollabPanel {
     width: Option<f32>,
@@ -577,7 +584,7 @@ impl CollabPanel {
                 workspace: workspace.weak_handle(),
                 client: workspace.app_state().client.clone(),
                 context_menu_on_selected: true,
-                drag_target_channel: None,
+                drag_target_channel: ChannelDragTarget::None,
                 list_state,
             };
 
@@ -1450,6 +1457,7 @@ impl CollabPanel {
         let mut channel_link = None;
         let mut channel_tooltip_text = None;
         let mut channel_icon = None;
+        let mut is_dragged_over = false;
 
         let text = match section {
             Section::ActiveCall => {
@@ -1533,26 +1541,37 @@ impl CollabPanel {
                     cx,
                 ),
             ),
-            Section::Channels => Some(
-                MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
-                    render_icon_button(
-                        theme
-                            .collab_panel
-                            .add_contact_button
-                            .style_for(is_selected, state),
-                        "icons/plus.svg",
-                    )
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
-                .with_tooltip::<AddChannel>(
-                    0,
-                    "Create a channel",
-                    None,
-                    tooltip_style.clone(),
-                    cx,
-                ),
-            ),
+            Section::Channels => {
+                if cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                    .is_some()
+                    && self.drag_target_channel == ChannelDragTarget::Root
+                {
+                    is_dragged_over = true;
+                }
+
+                Some(
+                    MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
+                        render_icon_button(
+                            theme
+                                .collab_panel
+                                .add_contact_button
+                                .style_for(is_selected, state),
+                            "icons/plus.svg",
+                        )
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
+                    .with_tooltip::<AddChannel>(
+                        0,
+                        "Create a channel",
+                        None,
+                        tooltip_style.clone(),
+                        cx,
+                    ),
+                )
+            }
             _ => None,
         };
 
@@ -1623,9 +1642,37 @@ impl CollabPanel {
                 .constrained()
                 .with_height(theme.collab_panel.row_height)
                 .contained()
-                .with_style(header_style.container)
+                .with_style(if is_dragged_over {
+                    theme.collab_panel.dragged_over_header
+                } else {
+                    header_style.container
+                })
         });
 
+        result = result
+            .on_move(move |_, this, cx| {
+                if cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                    .is_some()
+                {
+                    this.drag_target_channel = ChannelDragTarget::Root;
+                    cx.notify()
+                }
+            })
+            .on_up(MouseButton::Left, move |_, this, cx| {
+                if let Some((_, dragged_channel)) = cx
+                    .global::<DragAndDrop<Workspace>>()
+                    .currently_dragged::<Channel>(cx.window())
+                {
+                    this.channel_store
+                        .update(cx, |channel_store, cx| {
+                            channel_store.move_channel(dragged_channel.id, None, cx)
+                        })
+                        .detach_and_log_err(cx)
+                }
+            });
+
         if can_collapse {
             result = result
                 .with_cursor_style(CursorStyle::PointingHand)
@@ -1917,13 +1964,7 @@ impl CollabPanel {
             .global::<DragAndDrop<Workspace>>()
             .currently_dragged::<Channel>(cx.window())
             .is_some()
-            && self
-                .drag_target_channel
-                .as_ref()
-                .filter(|channel_id| {
-                    channel.parent_path.contains(channel_id) || channel.id == **channel_id
-                })
-                .is_some()
+            && self.drag_target_channel == ChannelDragTarget::Channel(channel_id)
         {
             is_dragged_over = true;
         }
@@ -2126,7 +2167,7 @@ impl CollabPanel {
                 )
         })
         .on_click(MouseButton::Left, move |_, this, cx| {
-            if this.drag_target_channel.take().is_none() {
+            if this.drag_target_channel == ChannelDragTarget::None {
                 if is_active {
                     this.open_channel_notes(&OpenChannelNotes { channel_id }, cx)
                 } else {
@@ -2147,7 +2188,7 @@ impl CollabPanel {
             {
                 this.channel_store
                     .update(cx, |channel_store, cx| {
-                        channel_store.move_channel(dragged_channel.id, channel_id, cx)
+                        channel_store.move_channel(dragged_channel.id, Some(channel_id), cx)
                     })
                     .detach_and_log_err(cx)
             }
@@ -2160,7 +2201,7 @@ impl CollabPanel {
                     .currently_dragged::<Channel>(cx.window())
                 {
                     if channel.id != dragged_channel.id {
-                        this.drag_target_channel = Some(channel.id);
+                        this.drag_target_channel = ChannelDragTarget::Channel(channel.id);
                     }
                     cx.notify()
                 }

crates/rpc/proto/zed.proto 🔗

@@ -1130,7 +1130,7 @@ message GetChannelMessagesById {
 
 message MoveChannel {
     uint64 channel_id = 1;
-    uint64 to = 2;
+    optional uint64 to = 2;
 }
 
 message JoinChannelBuffer {

crates/theme/src/theme.rs 🔗

@@ -250,6 +250,7 @@ pub struct CollabPanel {
     pub add_contact_button: Toggleable<Interactive<IconButton>>,
     pub add_channel_button: Toggleable<Interactive<IconButton>>,
     pub header_row: ContainedText,
+    pub dragged_over_header: ContainerStyle,
     pub subheader_row: Toggleable<Interactive<ContainedText>>,
     pub leave_call: Interactive<ContainedText>,
     pub contact_row: Toggleable<Interactive<ContainerStyle>>,

styles/src/style_tree/collab_panel.ts 🔗

@@ -210,6 +210,14 @@ export default function contacts_panel(): any {
                 right: SPACING,
             },
         },
+        dragged_over_header: {
+            margin: { top: SPACING },
+            padding: {
+                left: SPACING,
+                right: SPACING,
+            },
+            background: background(layer, "hovered"),
+        },
         subheader_row,
         leave_call: interactive({
             base: {
@@ -279,7 +287,7 @@ export default function contacts_panel(): any {
                 margin: {
                     left: CHANNEL_SPACING,
                 },
-            }
+            },
         },
         list_empty_label_container: {
             margin: {

styles/src/style_tree/search.ts 🔗

@@ -2,7 +2,6 @@ import { with_opacity } from "../theme/color"
 import { background, border, foreground, text } from "./components"
 import { interactive, toggleable } from "../element"
 import { useTheme } from "../theme"
-import { text_button } from "../component/text_button"
 
 const search_results = () => {
     const theme = useTheme()
@@ -36,7 +35,7 @@ export default function search(): any {
             left: 10,
             right: 4,
         },
-        margin: { right: SEARCH_ROW_SPACING }
+        margin: { right: SEARCH_ROW_SPACING },
     }
 
     const include_exclude_editor = {
@@ -378,7 +377,7 @@ export default function search(): any {
         modes_container: {
             padding: {
                 right: SEARCH_ROW_SPACING,
-            }
+            },
         },
         replace_icon: {
             icon: {