workspace: Add action to move focused panel to next dock position (#23317)

Dino created

This Pull Request introduces a new command `workspace: move focused
panel to next position` which finds the currently focused panel, if such
panel exists, and moves it to the next valid dock position, following
the order of `Left → Bottom → Right` and then starting again from the
left position.

In order to achieve this the following changes have been introduced:

* Add a new default implementation for `PanelHandle`, namely
`PanelHandle::move_to_next_position` which leverages
`PanelHandle::position`, `PanelHandle::position_is_valid` and
`PanelHandle::set_position` methods to update the panel's position to
the next valid position.
* Add a new method to the `workspace` module, `
move_focused_panel_to_next_position`, which is responsible for finding
the currently focused panel, if such a panel exists, and calling the
`move_to_next_position` method in the panel's handle.
* Add a new action to the `workspace` module,
`MoveFocusedPanelToNextPosition`, which is handled by the
`move_focused_panel_to_next_position` method.

Tests have also been added to the `workspace` module in order to
guarantee that the action is correctly updating the focused panel's
position.

Here's a quick video of it, in action 🔽 


https://github.com/user-attachments/assets/264d382b-5239-40aa-bc5e-5d569dec0734

Closes #23115 

Release Notes:

- Added new command to move the focused panel to the next valid dock
position – `workspace: move focused panel to next position` .

Change summary

crates/workspace/src/dock.rs      | 15 +++++
crates/workspace/src/workspace.rs | 87 +++++++++++++++++++++++++++++++++
2 files changed, 102 insertions(+)

Detailed changes

crates/workspace/src/dock.rs 🔗

@@ -76,6 +76,21 @@ pub trait PanelHandle: Send + Sync {
     fn focus_handle(&self, cx: &AppContext) -> FocusHandle;
     fn to_any(&self) -> AnyView;
     fn activation_priority(&self, cx: &AppContext) -> u32;
+    fn move_to_next_position(&self, cx: &mut WindowContext) {
+        let current_position = self.position(cx);
+        let next_position = [
+            DockPosition::Left,
+            DockPosition::Bottom,
+            DockPosition::Right,
+        ]
+        .into_iter()
+        .filter(|position| self.position_is_valid(*position, cx))
+        .skip_while(|valid_position| *valid_position != current_position)
+        .nth(1)
+        .unwrap_or(DockPosition::Left);
+
+        self.set_position(next_position, cx);
+    }
 }
 
 impl<T> PanelHandle for View<T>

crates/workspace/src/workspace.rs 🔗

@@ -133,6 +133,7 @@ actions!(
         CopyRelativePath,
         Feedback,
         FollowNextCollaborator,
+        MoveFocusedPanelToNextPosition,
         NewCenterTerminal,
         NewFile,
         NewFileSplitVertical,
@@ -1749,6 +1750,29 @@ impl Workspace {
         .detach_and_log_err(cx)
     }
 
+    pub fn move_focused_panel_to_next_position(
+        &mut self,
+        _: &MoveFocusedPanelToNextPosition,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let docks = [&self.left_dock, &self.bottom_dock, &self.right_dock];
+        let active_dock = docks
+            .into_iter()
+            .find(|dock| dock.focus_handle(cx).contains_focused(cx));
+
+        if let Some(dock) = active_dock {
+            dock.update(cx, |dock, cx| {
+                let active_panel = dock
+                    .active_panel()
+                    .filter(|panel| panel.focus_handle(cx).contains_focused(cx));
+
+                if let Some(panel) = active_panel {
+                    panel.move_to_next_position(cx);
+                }
+            })
+        }
+    }
+
     pub fn prepare_to_close(
         &mut self,
         close_intent: CloseIntent,
@@ -4492,6 +4516,7 @@ impl Workspace {
             .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(Self::move_focused_panel_to_next_position))
             .on_action(cx.listener(|workspace, _: &Unfollow, cx| {
                 let pane = workspace.active_pane().clone();
                 workspace.unfollow_in_pane(&pane, cx);
@@ -7971,6 +7996,68 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_move_focused_panel_to_next_position(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+
+        // Add a new panel to the right dock, opening the dock and setting the
+        // focus to the new panel.
+        let panel = workspace.update(cx, |workspace, cx| {
+            let panel = cx.new_view(|cx| TestPanel::new(DockPosition::Right, cx));
+            workspace.add_panel(panel.clone(), cx);
+
+            workspace
+                .right_dock()
+                .update(cx, |right_dock, cx| right_dock.set_open(true, cx));
+
+            workspace.toggle_panel_focus::<TestPanel>(cx);
+
+            panel
+        });
+
+        // Dispatch the `MoveFocusedPanelToNextPosition` action, moving the
+        // panel to the next valid position which, in this case, is the left
+        // dock.
+        cx.dispatch_action(MoveFocusedPanelToNextPosition);
+        workspace.update(cx, |workspace, cx| {
+            assert!(workspace.left_dock().read(cx).is_open());
+            assert_eq!(panel.read(cx).position, DockPosition::Left);
+        });
+
+        // Dispatch the `MoveFocusedPanelToNextPosition` action, moving the
+        // panel to the next valid position which, in this case, is the bottom
+        // dock.
+        cx.dispatch_action(MoveFocusedPanelToNextPosition);
+        workspace.update(cx, |workspace, cx| {
+            assert!(workspace.bottom_dock().read(cx).is_open());
+            assert_eq!(panel.read(cx).position, DockPosition::Bottom);
+        });
+
+        // Dispatch the `MoveFocusedPanelToNextPosition` action again, this time
+        // around moving the panel to its initial position, the right dock.
+        cx.dispatch_action(MoveFocusedPanelToNextPosition);
+        workspace.update(cx, |workspace, cx| {
+            assert!(workspace.right_dock().read(cx).is_open());
+            assert_eq!(panel.read(cx).position, DockPosition::Right);
+        });
+
+        // Remove focus from the panel, ensuring that, if the panel is not
+        // focused, the `MoveFocusedPanelToNextPosition` action does not update
+        // the panel's position, so the panel is still in the right dock.
+        workspace.update(cx, |workspace, cx| {
+            workspace.toggle_panel_focus::<TestPanel>(cx);
+        });
+
+        cx.dispatch_action(MoveFocusedPanelToNextPosition);
+        workspace.update(cx, |workspace, cx| {
+            assert!(workspace.right_dock().read(cx).is_open());
+            assert_eq!(panel.read(cx).position, DockPosition::Right);
+        });
+    }
+
     mod register_project_item_tests {
         use gpui::Context as _;