Alternate files with ctrl-6 (#11367)

Robert FalkΓ©n and Conrad Irwin created

This is my stab at #7709 

I realize the code is flawed. There's no test coverage, I'm using
`clone()` and there are probably better ways to hook into the events.
Also, I didn't know what context to use for the keybinding. But maybe
with some pointers from someone who actually know what they're doing, I
can get this shippable.

Release Notes:

- vim: Added ctrl-6 for
[alternate-file](https://vimhelp.org/editing.txt.html#CTRL-%5E) to
navigate back and forth between two buffers.



https://github.com/zed-industries/zed/assets/261929/2d10494e-5668-4988-b7b4-417c922d6c61

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json           |  6 ++++
crates/workspace/src/item.rs      |  5 +++
crates/workspace/src/pane.rs      | 43 +++++++++++++++++++++++++++++++++
crates/workspace/src/workspace.rs |  6 ++++
4 files changed, 60 insertions(+)

Detailed changes

assets/keymaps/vim.json πŸ”—

@@ -1,4 +1,10 @@
 [
+  {
+    "context": "ProjectPanel || Editor",
+    "bindings": {
+      "ctrl-6": "pane::AlternateFile"
+    }
+  },
   {
     "context": "Editor && VimControl && !VimWaiting && !menu",
     "bindings": {

crates/workspace/src/item.rs πŸ”—

@@ -330,6 +330,7 @@ pub trait ItemHandle: 'static + Send {
     fn serialized_item_kind(&self) -> Option<&'static str>;
     fn show_toolbar(&self, cx: &AppContext) -> bool;
     fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Point<Pixels>>;
+    fn downgrade_item(&self) -> Box<dyn WeakItemHandle>;
 }
 
 pub trait WeakItemHandle: Send + Sync {
@@ -702,6 +703,10 @@ impl<T: Item> ItemHandle for View<T> {
     fn pixel_position_of_cursor(&self, cx: &AppContext) -> Option<Point<Pixels>> {
         self.read(cx).pixel_position_of_cursor(cx)
     }
+
+    fn downgrade_item(&self) -> Box<dyn WeakItemHandle> {
+        Box::new(self.downgrade())
+    }
 }
 
 impl From<Box<dyn ItemHandle>> for AnyView {

crates/workspace/src/pane.rs πŸ”—

@@ -18,6 +18,7 @@ use gpui::{
     MouseDownEvent, NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle,
     Subscription, Task, View, ViewContext, VisualContext, WeakFocusHandle, WeakView, WindowContext,
 };
+use itertools::Itertools;
 use parking_lot::Mutex;
 use project::{Project, ProjectEntryId, ProjectPath};
 use serde::Deserialize;
@@ -114,6 +115,7 @@ actions!(
         ActivatePrevItem,
         ActivateNextItem,
         ActivateLastItem,
+        AlternateFile,
         CloseCleanItems,
         CloseItemsToTheLeft,
         CloseItemsToTheRight,
@@ -183,6 +185,10 @@ impl fmt::Debug for Event {
 /// responsible for managing item tabs, focus and zoom states and drag and drop features.
 /// Can be split, see `PaneGroup` for more details.
 pub struct Pane {
+    alternate_file_items: (
+        Option<Box<dyn WeakItemHandle>>,
+        Option<Box<dyn WeakItemHandle>>,
+    ),
     focus_handle: FocusHandle,
     items: Vec<Box<dyn ItemHandle>>,
     activation_history: Vec<EntityId>,
@@ -286,6 +292,7 @@ impl Pane {
 
         let handle = cx.view().downgrade();
         Self {
+            alternate_file_items: (None, None),
             focus_handle,
             items: Vec::new(),
             activation_history: Vec::new(),
@@ -390,6 +397,39 @@ impl Pane {
         }
     }
 
+    fn alternate_file(&mut self, cx: &mut ViewContext<Pane>) {
+        let (_, alternative) = &self.alternate_file_items;
+        if let Some(alternative) = alternative {
+            let existing = self
+                .items()
+                .find_position(|item| item.item_id() == alternative.id());
+            if let Some((ix, _)) = existing {
+                self.activate_item(ix, true, true, cx);
+            } else {
+                if let Some(upgraded) = alternative.upgrade() {
+                    self.add_item(upgraded, true, true, None, cx);
+                }
+            }
+        }
+    }
+
+    pub fn track_alternate_file_items(&mut self) {
+        if let Some(item) = self.active_item().map(|item| item.downgrade_item()) {
+            let (current, _) = &self.alternate_file_items;
+            match current {
+                Some(current) => {
+                    if current.id() != item.id() {
+                        self.alternate_file_items =
+                            (Some(item), self.alternate_file_items.0.take());
+                    }
+                }
+                None => {
+                    self.alternate_file_items = (Some(item), None);
+                }
+            }
+        }
+    }
+
     pub fn has_focus(&self, cx: &WindowContext) -> bool {
         // We not only check whether our focus handle contains focus, but also
         // whether the active_item might have focus, because we might have just activated an item
@@ -1981,6 +2021,9 @@ impl Render for Pane {
             .size_full()
             .flex_none()
             .overflow_hidden()
+            .on_action(cx.listener(|pane, _: &AlternateFile, cx| {
+                pane.alternate_file(cx);
+            }))
             .on_action(cx.listener(|pane, _: &SplitLeft, cx| pane.split(SplitDirection::Left, cx)))
             .on_action(cx.listener(|pane, _: &SplitUp, cx| pane.split(SplitDirection::Up, cx)))
             .on_action(

crates/workspace/src/workspace.rs πŸ”—

@@ -2499,6 +2499,9 @@ impl Workspace {
         self.zoomed_position = None;
         cx.emit(Event::ZoomChanged);
         self.update_active_view_for_followers(cx);
+        pane.model.update(cx, |pane, _| {
+            pane.track_alternate_file_items();
+        });
 
         cx.notify();
     }
@@ -2516,6 +2519,9 @@ impl Workspace {
             }
             pane::Event::Remove => self.remove_pane(pane, cx),
             pane::Event::ActivateItem { local } => {
+                pane.model.update(cx, |pane, _| {
+                    pane.track_alternate_file_items();
+                });
                 if *local {
                     self.unfollow(&pane, cx);
                 }