Workspace move editor actions (#21760)

Ignat S. and Kirill Bulatov created

Closes #20205

Release Notes:

- Added `MoveItemToPane` and `MoveItemToPaneInDirection` actions

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

assets/keymaps/linux/sublime_text.json     | 24 +++++++
assets/keymaps/macos/sublime_text.json     | 22 ++++++
crates/terminal_view/src/terminal_panel.rs | 51 +++++++++++----
crates/workspace/src/workspace.rs          | 73 +++++++++++++++++++++++
4 files changed, 150 insertions(+), 20 deletions(-)

Detailed changes

assets/keymaps/linux/sublime_text.json 🔗

@@ -4,12 +4,32 @@
       "ctrl-shift-[": "pane::ActivatePrevItem",
       "ctrl-shift-]": "pane::ActivateNextItem",
       "ctrl-pageup": "pane::ActivatePrevItem",
-      "ctrl-pagedown": "pane::ActivateNextItem"
+      "ctrl-pagedown": "pane::ActivateNextItem",
+      "ctrl-1": ["workspace::ActivatePane", 0],
+      "ctrl-2": ["workspace::ActivatePane", 1],
+      "ctrl-3": ["workspace::ActivatePane", 2],
+      "ctrl-4": ["workspace::ActivatePane", 3],
+      "ctrl-5": ["workspace::ActivatePane", 4],
+      "ctrl-6": ["workspace::ActivatePane", 5],
+      "ctrl-7": ["workspace::ActivatePane", 6],
+      "ctrl-8": ["workspace::ActivatePane", 7],
+      "ctrl-9": ["workspace::ActivatePane", 8],
+      "ctrl-shift-1": ["workspace::MoveItemToPane", { "destination": 0, "focus": true }],
+      "ctrl-shift-2": ["workspace::MoveItemToPane", { "destination": 1 }],
+      "ctrl-shift-3": ["workspace::MoveItemToPane", { "destination": 2 }],
+      "ctrl-shift-4": ["workspace::MoveItemToPane", { "destination": 3 }],
+      "ctrl-shift-5": ["workspace::MoveItemToPane", { "destination": 4 }],
+      "ctrl-shift-6": ["workspace::MoveItemToPane", { "destination": 5 }],
+      "ctrl-shift-7": ["workspace::MoveItemToPane", { "destination": 6 }],
+      "ctrl-shift-8": ["workspace::MoveItemToPane", { "destination": 7 }],
+      "ctrl-shift-9": ["workspace::MoveItemToPane", { "destination": 8 }]
     }
   },
   {
     "context": "Editor",
     "bindings": {
+      "ctrl-alt-up": "editor::AddSelectionAbove",
+      "ctrl-alt-down": "editor::AddSelectionBelow",
       "ctrl-shift-up": "editor::MoveLineUp",
       "ctrl-shift-down": "editor::MoveLineDown",
       "ctrl-shift-m": "editor::SelectLargerSyntaxNode",
@@ -17,6 +37,8 @@
       "ctrl-shift-a": "editor::SelectLargerSyntaxNode",
       "ctrl-shift-d": "editor::DuplicateSelection",
       "alt-f3": "editor::SelectAllMatches", // find_all_under
+      "f9": "editor::SortLinesCaseSensitive",
+      "ctrl-f9": "editor::SortLinesCaseInsensitive",
       "f12": "editor::GoToDefinition",
       "ctrl-f12": "editor::GoToDefinitionSplit",
       "shift-f12": "editor::FindAllReferences",

assets/keymaps/macos/sublime_text.json 🔗

@@ -4,7 +4,25 @@
       "cmd-shift-[": "pane::ActivatePrevItem",
       "cmd-shift-]": "pane::ActivateNextItem",
       "ctrl-pageup": "pane::ActivatePrevItem",
-      "ctrl-pagedown": "pane::ActivateNextItem"
+      "ctrl-pagedown": "pane::ActivateNextItem",
+      "ctrl-1": ["workspace::ActivatePane", 0],
+      "ctrl-2": ["workspace::ActivatePane", 1],
+      "ctrl-3": ["workspace::ActivatePane", 2],
+      "ctrl-4": ["workspace::ActivatePane", 3],
+      "ctrl-5": ["workspace::ActivatePane", 4],
+      "ctrl-6": ["workspace::ActivatePane", 5],
+      "ctrl-7": ["workspace::ActivatePane", 6],
+      "ctrl-8": ["workspace::ActivatePane", 7],
+      "ctrl-9": ["workspace::ActivatePane", 8],
+      "ctrl-shift-1": ["workspace::MoveItemToPane", { "destination": 0, "focus": true }],
+      "ctrl-shift-2": ["workspace::MoveItemToPane", { "destination": 1 }],
+      "ctrl-shift-3": ["workspace::MoveItemToPane", { "destination": 2 }],
+      "ctrl-shift-4": ["workspace::MoveItemToPane", { "destination": 3 }],
+      "ctrl-shift-5": ["workspace::MoveItemToPane", { "destination": 4 }],
+      "ctrl-shift-6": ["workspace::MoveItemToPane", { "destination": 5 }],
+      "ctrl-shift-7": ["workspace::MoveItemToPane", { "destination": 6 }],
+      "ctrl-shift-8": ["workspace::MoveItemToPane", { "destination": 7 }],
+      "ctrl-shift-9": ["workspace::MoveItemToPane", { "destination": 8 }]
     }
   },
   {
@@ -20,6 +38,8 @@
       "cmd-shift-a": "editor::SelectLargerSyntaxNode",
       "cmd-shift-d": "editor::DuplicateSelection",
       "ctrl-cmd-g": "editor::SelectAllMatches", // find_all_under
+      "f5": "editor::SortLinesCaseSensitive",
+      "ctrl-f5": "editor::SortLinesCaseInsensitive",
       "shift-f12": "editor::FindAllReferences",
       "alt-cmd-down": "editor::GoToDefinition",
       "ctrl-alt-cmd-down": "editor::GoToDefinitionSplit",

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -33,11 +33,12 @@ use util::{ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     item::SerializableItem,
-    move_item, pane,
+    move_active_item, move_item, pane,
     ui::IconName,
     ActivateNextPane, ActivatePane, ActivatePaneInDirection, ActivatePreviousPane, DraggedTab,
-    ItemId, NewTerminal, Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight,
-    SplitUp, SwapPaneInDirection, ToggleZoom, Workspace,
+    ItemId, MoveItemToPane, MoveItemToPaneInDirection, NewTerminal, Pane, PaneGroup,
+    SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneInDirection, ToggleZoom,
+    Workspace,
 };
 
 use anyhow::{anyhow, Context, Result};
@@ -355,7 +356,7 @@ impl TerminalPanel {
         &mut self,
         cx: &mut ViewContext<Self>,
     ) -> Option<View<Pane>> {
-        let workspace = self.workspace.clone().upgrade()?;
+        let workspace = self.workspace.upgrade()?;
         let workspace = workspace.read(cx);
         let database_id = workspace.database_id();
         let weak_workspace = self.workspace.clone();
@@ -1181,8 +1182,7 @@ impl Render for TerminalPanel {
                             .position(|pane| **pane == terminal_panel.active_pane)
                         {
                             let next_ix = (ix + 1) % panes.len();
-                            let next_pane = panes[next_ix].clone();
-                            cx.focus_view(&next_pane);
+                            cx.focus_view(&panes[next_ix]);
                         }
                     }),
                 )
@@ -1194,15 +1194,14 @@ impl Render for TerminalPanel {
                             .position(|pane| **pane == terminal_panel.active_pane)
                         {
                             let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1);
-                            let prev_pane = panes[prev_ix].clone();
-                            cx.focus_view(&prev_pane);
+                            cx.focus_view(&panes[prev_ix]);
                         }
                     }),
                 )
                 .on_action(cx.listener(|terminal_panel, action: &ActivatePane, cx| {
                     let panes = terminal_panel.center.panes();
-                    if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) {
-                        cx.focus_view(&pane);
+                    if let Some(&pane) = panes.get(action.0) {
+                        cx.focus_view(pane);
                     } else {
                         if let Some(new_pane) =
                             terminal_panel.new_pane_with_cloned_active_terminal(cx)
@@ -1219,18 +1218,40 @@ impl Render for TerminalPanel {
                         }
                     }
                 }))
-                .on_action(cx.listener(
-                    |terminal_panel, action: &SwapPaneInDirection, cx| {
+                .on_action(
+                    cx.listener(|terminal_panel, action: &SwapPaneInDirection, cx| {
                         if let Some(to) = terminal_panel
                             .center
                             .find_pane_in_direction(&terminal_panel.active_pane, action.0, cx)
                             .cloned()
                         {
-                            terminal_panel
-                                .center
-                                .swap(&terminal_panel.active_pane.clone(), &to);
+                            terminal_panel.center.swap(&terminal_panel.active_pane, &to);
                             cx.notify();
                         }
+                    }),
+                )
+                .on_action(cx.listener(|terminal_panel, action: &MoveItemToPane, cx| {
+                    let Some(&target_pane) = terminal_panel.center.panes().get(action.destination)
+                    else {
+                        return;
+                    };
+                    move_active_item(
+                        &terminal_panel.active_pane,
+                        target_pane,
+                        action.focus,
+                        true,
+                        cx,
+                    );
+                }))
+                .on_action(cx.listener(
+                    |terminal_panel, action: &MoveItemToPaneInDirection, cx| {
+                        let source_pane = &terminal_panel.active_pane;
+                        if let Some(destination_pane) = terminal_panel
+                            .center
+                            .find_pane_in_direction(source_pane, action.direction, cx)
+                        {
+                            move_active_item(source_pane, destination_pane, action.focus, true, cx);
+                        };
                     },
                 ))
             })

crates/workspace/src/workspace.rs 🔗

@@ -93,7 +93,7 @@ use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
 pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
 pub use ui;
 use ui::prelude::*;
-use util::{paths::SanitizedPath, ResultExt, TryFutureExt};
+use util::{paths::SanitizedPath, serde::default_true, ResultExt, TryFutureExt};
 use uuid::Uuid;
 pub use workspace_settings::{
     AutosaveSetting, RestoreOnStartupBehavior, TabBarSettings, WorkspaceSettings,
@@ -173,6 +173,20 @@ pub struct ActivatePaneInDirection(pub SplitDirection);
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct SwapPaneInDirection(pub SplitDirection);
 
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct MoveItemToPane {
+    pub destination: usize,
+    #[serde(default = "default_true")]
+    pub focus: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct MoveItemToPaneInDirection {
+    pub direction: SplitDirection,
+    #[serde(default = "default_true")]
+    pub focus: bool,
+}
+
 #[derive(Clone, PartialEq, Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
 pub struct SaveAll {
@@ -222,6 +236,8 @@ impl_actions!(
         ActivatePaneInDirection,
         CloseAllItemsAndPanes,
         CloseInactiveTabsAndPanes,
+        MoveItemToPane,
+        MoveItemToPaneInDirection,
         OpenTerminal,
         Reload,
         Save,
@@ -2829,6 +2845,13 @@ impl Workspace {
         }
     }
 
+    fn move_item_to_pane_at_index(&mut self, action: &MoveItemToPane, cx: &mut ViewContext<Self>) {
+        let Some(&target_pane) = self.center.panes().get(action.destination) else {
+            return;
+        };
+        move_active_item(&self.active_pane, target_pane, action.focus, true, cx);
+    }
+
     pub fn activate_next_pane(&mut self, cx: &mut WindowContext) {
         let panes = self.center.panes();
         if let Some(ix) = panes.iter().position(|pane| **pane == self.active_pane) {
@@ -2947,6 +2970,16 @@ impl Workspace {
         }
     }
 
+    pub fn move_item_to_pane_in_direction(
+        &mut self,
+        action: &MoveItemToPaneInDirection,
+        cx: &mut WindowContext,
+    ) {
+        if let Some(destination) = self.find_pane_in_direction(action.direction, cx) {
+            move_active_item(&self.active_pane, &destination, action.focus, true, cx);
+        }
+    }
+
     pub fn bounding_box_for_pane(&self, pane: &View<Pane>) -> Option<Bounds<Pixels>> {
         self.center.bounding_box_for_pane(pane)
     }
@@ -2967,14 +3000,14 @@ impl Workspace {
         cx: &mut ViewContext<Self>,
     ) {
         if let Some(to) = self.find_pane_in_direction(direction, cx) {
-            self.center.swap(&self.active_pane.clone(), &to);
+            self.center.swap(&self.active_pane, &to);
             cx.notify();
         }
     }
 
     pub fn resize_pane(&mut self, axis: gpui::Axis, amount: Pixels, cx: &mut ViewContext<Self>) {
         self.center
-            .resize(&self.active_pane.clone(), axis, amount, &self.bounds);
+            .resize(&self.active_pane, axis, amount, &self.bounds);
         cx.notify();
     }
 
@@ -4408,6 +4441,7 @@ impl Workspace {
             .on_action(cx.listener(Self::follow_next_collaborator))
             .on_action(cx.listener(Self::close_window))
             .on_action(cx.listener(Self::activate_pane_at_index))
+            .on_action(cx.listener(Self::move_item_to_pane_at_index))
             .on_action(cx.listener(|workspace, _: &Unfollow, cx| {
                 let pane = workspace.active_pane().clone();
                 workspace.unfollow_in_pane(&pane, cx);
@@ -4438,6 +4472,11 @@ impl Workspace {
                     workspace.activate_pane_in_direction(action.0, cx)
                 }),
             )
+            .on_action(
+                cx.listener(|workspace, action: &MoveItemToPaneInDirection, cx| {
+                    workspace.move_item_to_pane_in_direction(action, cx)
+                }),
+            )
             .on_action(cx.listener(|workspace, action: &SwapPaneInDirection, cx| {
                 workspace.swap_pane_in_direction(action.0, cx)
             }))
@@ -6181,6 +6220,34 @@ pub fn move_item(
     });
 }
 
+pub fn move_active_item(
+    source: &View<Pane>,
+    destination: &View<Pane>,
+    focus_destination: bool,
+    close_if_empty: bool,
+    cx: &mut WindowContext<'_>,
+) {
+    if source == destination {
+        return;
+    }
+    let Some(active_item) = source.read(cx).active_item() else {
+        return;
+    };
+    source.update(cx, |source_pane, cx| {
+        let item_id = active_item.item_id();
+        source_pane.remove_item(item_id, false, close_if_empty, cx);
+        destination.update(cx, |target_pane, cx| {
+            target_pane.add_item(
+                active_item,
+                focus_destination,
+                focus_destination,
+                Some(target_pane.items_len()),
+                cx,
+            );
+        });
+    });
+}
+
 #[cfg(test)]
 mod tests {
     use std::{cell::RefCell, rc::Rc};