workspace: Add ActivateLastPane action (#49853)

xj and xj created

## Summary

Add `workspace::ActivateLastPane` so users can bind a shortcut (for
example `cmd-9`) to focus the last pane.

## Why

Today, the closest option is `workspace::ActivatePane` with an index
(for example `8`), but that has side effects: when the index does not
exist, it creates/splits panes (`activate_pane_at_index` fallback).

`ActivateLastPane` gives a stable, no-surprises target: focus the
rightmost/last pane in current pane order, never create a new pane.

## Context

This capability has been requested by users before:
- https://github.com/zed-industries/zed/issues/17503#event-22959656321

## Prior art

VS Code exposes explicit editor-group focus commands and index-based
focus patterns (e.g. `workbench.action.focusSecondEditorGroup` ...
`focusEighthEditorGroup`) in its workbench commands:
-
https://github.com/microsoft/vscode/blob/main/src/vs/workbench/browser/parts/editor/editorCommands.ts#L675-L724

Zed already follows numbered pane focus in default keymaps
(`ActivatePane` 1..9 on macOS/Linux/Windows), so adding a dedicated
"last pane" action is a small, natural extension:
- `assets/keymaps/default-macos.json`
- `assets/keymaps/default-linux.json`
- `assets/keymaps/default-windows.json`

## Change

- Added `workspace::ActivateLastPane`
- Implemented `Workspace::activate_last_pane(...)`
- Wired action handler in workspace listeners
- Added `test_activate_last_pane`

## Validation

- `cargo test -p workspace test_activate_last_pane -- --nocapture`
- `cargo test -p workspace test_pane_navigation -- --nocapture`
- `cargo fmt --all -- --check`

## Risk

Low: focus-only behavior, no layout/data changes, no default keymap
changes.

Release Notes:

- Added `workspace::ActivateLastPane` action for keybindings that focus
the last pane.

---------

Co-authored-by: xj <gh-xj@users.noreply.github.com>

Change summary

crates/workspace/src/workspace.rs | 64 +++++++++++++++++++++++++++++++-
1 file changed, 61 insertions(+), 3 deletions(-)

Detailed changes

crates/workspace/src/workspace.rs 🔗

@@ -210,6 +210,8 @@ actions!(
         ActivateNextPane,
         /// Activates the previous pane in the workspace.
         ActivatePreviousPane,
+        /// Activates the last pane in the workspace.
+        ActivateLastPane,
         /// Switches to the next window.
         ActivateNextWindow,
         /// Switches to the previous window.
@@ -4331,6 +4333,11 @@ impl Workspace {
         }
     }
 
+    pub fn activate_last_pane(&mut self, window: &mut Window, cx: &mut App) {
+        let last_pane = self.center.last_pane();
+        window.focus(&last_pane.focus_handle(cx), cx);
+    }
+
     pub fn activate_pane_in_direction(
         &mut self,
         direction: SplitDirection,
@@ -6381,6 +6388,9 @@ impl Workspace {
             .on_action(cx.listener(|workspace, _: &ActivateNextPane, window, cx| {
                 workspace.activate_next_pane(window, cx)
             }))
+            .on_action(cx.listener(|workspace, _: &ActivateLastPane, window, cx| {
+                workspace.activate_last_pane(window, cx)
+            }))
             .on_action(
                 cx.listener(|workspace, _: &ActivateNextWindow, _window, cx| {
                     workspace.activate_next_window(cx)
@@ -6403,9 +6413,6 @@ impl Workspace {
             .on_action(cx.listener(|workspace, _: &ActivatePaneDown, window, cx| {
                 workspace.activate_pane_in_direction(SplitDirection::Down, window, cx)
             }))
-            .on_action(cx.listener(|workspace, _: &ActivateNextPane, window, cx| {
-                workspace.activate_next_pane(window, cx)
-            }))
             .on_action(cx.listener(
                 |workspace, action: &MoveItemToPaneInDirection, window, cx| {
                     workspace.move_item_to_pane_in_direction(action, window, cx)
@@ -10552,6 +10559,57 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_activate_last_pane(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(|window, cx| Workspace::test_new(project, window, cx));
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            let first_item = cx.new(|cx| {
+                TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
+            });
+            workspace.add_item_to_active_pane(Box::new(first_item), None, true, window, cx);
+            workspace.split_pane(
+                workspace.active_pane().clone(),
+                SplitDirection::Right,
+                window,
+                cx,
+            );
+            workspace.split_pane(
+                workspace.active_pane().clone(),
+                SplitDirection::Right,
+                window,
+                cx,
+            );
+        });
+
+        let (first_pane_id, target_last_pane_id) = workspace.update(cx, |workspace, _cx| {
+            let panes = workspace.center.panes();
+            assert!(panes.len() >= 2);
+            (
+                panes.first().expect("at least one pane").entity_id(),
+                panes.last().expect("at least one pane").entity_id(),
+            )
+        });
+
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.activate_pane_at_index(&ActivatePane(0), window, cx);
+        });
+        workspace.update(cx, |workspace, _| {
+            assert_eq!(workspace.active_pane().entity_id(), first_pane_id);
+            assert_ne!(workspace.active_pane().entity_id(), target_last_pane_id);
+        });
+
+        cx.dispatch_action(ActivateLastPane);
+
+        workspace.update(cx, |workspace, _| {
+            assert_eq!(workspace.active_pane().entity_id(), target_last_pane_id);
+        });
+    }
+
     #[gpui::test]
     async fn test_toggle_docks_and_panels(cx: &mut gpui::TestAppContext) {
         init_test(cx);