Add "Open in Terminal" context menu entries for project panel, editor and tab context menus (#10741)

Kirill Bulatov created

Closes https://github.com/zed-industries/zed/issues/4566

Pane tabs (does not exist for multibuffer tabs):
<img width="439" alt="Screenshot 2024-04-18 at 23 01 08"
src="https://github.com/zed-industries/zed/assets/2690773/3af79ed8-07ea-4cf2-bcf9-735b1b3be8c4">

Editor context menu:
<img width="404" alt="Screenshot 2024-04-18 at 23 01 14"
src="https://github.com/zed-industries/zed/assets/2690773/38ea7afc-df2b-45ef-8331-eb6a4588af9f">

Project panel context menu (was not shown for file entries before this):
<img width="408" alt="Screenshot 2024-04-18 at 23 01 18"
src="https://github.com/zed-industries/zed/assets/2690773/e336fce1-7da0-4671-b8d2-8d3409c23eb6">

Release Notes:

- (breaking change) Moved `project_panel::OpenInTerminal` into
`workspace::OpenInTerminal` action and add it in editors, tab context
menus and proper panel file entries
([4566](https://github.com/zed-industries/zed/issues/4566))

Change summary

crates/editor/src/editor.rs                | 21 +++++++
crates/editor/src/element.rs               |  1 
crates/editor/src/mouse_context_menu.rs    |  2 
crates/project_panel/src/project_panel.rs  | 26 +++++---
crates/terminal_view/src/terminal_panel.rs | 28 +++------
crates/workspace/src/pane.rs               | 67 ++++++++++++++++++-----
crates/workspace/src/workspace.rs          |  1 
7 files changed, 101 insertions(+), 45 deletions(-)

Detailed changes

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

@@ -131,10 +131,10 @@ use ui::{
 use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt};
 use workspace::item::ItemHandle;
 use workspace::notifications::NotificationId;
-use workspace::Toast;
 use workspace::{
     searchable::SearchEvent, ItemNavHistory, SplitDirection, ViewId, Workspace, WorkspaceId,
 };
+use workspace::{OpenInTerminal, OpenTerminal, Toast};
 
 use crate::hover_links::find_url;
 
@@ -4943,6 +4943,25 @@ impl Editor {
         }
     }
 
+    pub fn open_active_item_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
+        if let Some(working_directory) = self.active_excerpt(cx).and_then(|(_, buffer, _)| {
+            let project_path = buffer.read(cx).project_path(cx)?;
+            let project = self.project.as_ref()?.read(cx);
+            let entry = project.entry_for_path(&project_path, cx)?;
+            let abs_path = project.absolute_path(&project_path, cx)?;
+            let parent = if entry.is_symlink {
+                abs_path.canonicalize().ok()?
+            } else {
+                abs_path
+            }
+            .parent()?
+            .to_path_buf();
+            Some(parent)
+        }) {
+            cx.dispatch_action(OpenTerminal { working_directory }.boxed_clone());
+        }
+    }
+
     fn gather_revert_changes(
         &mut self,
         selections: &[Selection<Anchor>],

crates/editor/src/element.rs πŸ”—

@@ -363,6 +363,7 @@ impl EditorElement {
         register_action(view, cx, Editor::unique_lines_case_sensitive);
         register_action(view, cx, Editor::accept_partial_inline_completion);
         register_action(view, cx, Editor::revert_selected_hunks);
+        register_action(view, cx, Editor::open_active_item_in_terminal)
     }
 
     fn register_key_listeners(&self, cx: &mut ElementContext, layout: &EditorLayout) {

crates/editor/src/mouse_context_menu.rs πŸ”—

@@ -3,6 +3,7 @@ use crate::{
     GoToTypeDefinition, Rename, RevealInFinder, SelectMode, ToggleCodeActions,
 };
 use gpui::{DismissEvent, Pixels, Point, Subscription, View, ViewContext};
+use workspace::OpenInTerminal;
 
 pub struct MouseContextMenu {
     pub(crate) position: Point<Pixels>,
@@ -83,6 +84,7 @@ pub fn deploy_context_menu(
                 )
                 .separator()
                 .action("Reveal in Finder", Box::new(RevealInFinder))
+                .action("Open in Terminal", Box::new(OpenInTerminal))
         })
     };
     let mouse_context_menu = MouseContextMenu::new(position, context_menu, cx);

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

@@ -37,7 +37,7 @@ use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::DetachAndPromptErr,
-    Workspace,
+    OpenInTerminal, Workspace,
 };
 
 const PROJECT_PANEL_KEY: &str = "ProjectPanel";
@@ -127,7 +127,6 @@ actions!(
         CopyPath,
         CopyRelativePath,
         RevealInFinder,
-        OpenInTerminal,
         Cut,
         Paste,
         Rename,
@@ -441,9 +440,7 @@ impl ProjectPanel {
                             .action("New Folder", Box::new(NewDirectory))
                             .separator()
                             .action("Reveal in Finder", Box::new(RevealInFinder))
-                            .when(is_dir, |menu| {
-                                menu.action("Open in Terminal…", Box::new(OpenInTerminal))
-                            })
+                            .action("Open in Terminal", Box::new(OpenInTerminal))
                             .when(is_dir, |menu| {
                                 menu.separator()
                                     .action("Find in Folder…", Box::new(NewSearchInDirectory))
@@ -1131,13 +1128,20 @@ impl ProjectPanel {
 
     fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
         if let Some((worktree, entry)) = self.selected_entry(cx) {
-            let path = worktree.abs_path().join(&entry.path);
-            cx.dispatch_action(
-                workspace::OpenTerminal {
-                    working_directory: path,
+            let abs_path = worktree.abs_path().join(&entry.path);
+            let working_directory = if entry.is_dir() {
+                Some(abs_path)
+            } else {
+                if entry.is_symlink {
+                    abs_path.canonicalize().ok()
+                } else {
+                    Some(abs_path)
                 }
-                .boxed_clone(),
-            )
+                .and_then(|path| Some(path.parent()?.to_path_buf()))
+            };
+            if let Some(working_directory) = working_directory {
+                cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
+            }
         }
     }
 

crates/terminal_view/src/terminal_panel.rs πŸ”—

@@ -98,7 +98,6 @@ impl TerminalPanel {
                             .on_click(cx.listener(|pane, _, cx| {
                                 pane.toggle_zoom(&workspace::ToggleZoom, cx);
                             }))
-                            // TODO kb
                             .tooltip(move |cx| {
                                 Tooltip::for_action(
                                     if zoomed { "Zoom Out" } else { "Zoom In" },
@@ -292,13 +291,13 @@ impl TerminalPanel {
         action: &workspace::OpenTerminal,
         cx: &mut ViewContext<Workspace>,
     ) {
-        let Some(this) = workspace.focus_panel::<Self>(cx) else {
+        let Some(terminal_panel) = workspace.panel::<Self>(cx) else {
             return;
         };
-
-        this.update(cx, |this, cx| {
-            this.add_terminal(Some(action.working_directory.clone()), None, cx)
-        })
+        terminal_panel.update(cx, |panel, cx| {
+            panel.add_terminal(Some(action.working_directory.clone()), None, cx)
+        });
+        workspace.focus_panel::<Self>(cx);
     }
 
     fn spawn_task(&mut self, spawn_in_terminal: &SpawnInTerminal, cx: &mut ViewContext<Self>) {
@@ -427,26 +426,17 @@ impl TerminalPanel {
         }
     }
 
-    ///Create a new Terminal in the current working directory or the user's home directory
+    /// Create a new Terminal in the current working directory or the user's home directory
     fn new_terminal(
         workspace: &mut Workspace,
         _: &workspace::NewTerminal,
         cx: &mut ViewContext<Workspace>,
     ) {
-        let has_no_terminals = workspace
-            .panel::<Self>(cx)
-            .map(|terminal_panel| terminal_panel.update(cx, |panel, cx| panel.has_no_terminals(cx)))
-            .unwrap_or(true);
-        let Some(this) = workspace.focus_panel::<Self>(cx) else {
+        let Some(terminal_panel) = workspace.panel::<Self>(cx) else {
             return;
         };
-        if has_no_terminals {
-            // `set_active` on focus, will already add a new terminal
-            // into an empty terminal pane, no need to add another one
-            return;
-        }
-
-        this.update(cx, |this, cx| this.add_terminal(None, None, cx))
+        terminal_panel.update(cx, |this, cx| this.add_terminal(None, None, cx));
+        workspace.focus_panel::<Self>(cx);
     }
 
     fn terminals_for_task(

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

@@ -5,7 +5,8 @@ use crate::{
     },
     toolbar::Toolbar,
     workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
-    NewCenterTerminal, NewFile, NewSearch, OpenVisible, SplitDirection, ToggleZoom, Workspace,
+    NewCenterTerminal, NewFile, NewSearch, OpenInTerminal, OpenTerminal, OpenVisible,
+    SplitDirection, ToggleZoom, Workspace,
 };
 use anyhow::Result;
 use collections::{HashMap, HashSet, VecDeque};
@@ -1597,20 +1598,58 @@ impl Pane {
                         );
 
                     if let Some(entry) = single_entry_to_resolve {
+                        let parent_abs_path = pane
+                            .update(cx, |pane, cx| {
+                                pane.workspace.update(cx, |workspace, cx| {
+                                    let project = workspace.project().read(cx);
+                                    project.worktree_for_entry(entry, cx).and_then(|worktree| {
+                                        let worktree = worktree.read(cx);
+                                        let entry = worktree.entry_for_id(entry)?;
+                                        let abs_path = worktree.absolutize(&entry.path).ok()?;
+                                        let parent = if entry.is_symlink {
+                                            abs_path.canonicalize().ok()?
+                                        } else {
+                                            abs_path
+                                        }
+                                        .parent()?
+                                        .to_path_buf();
+                                        Some(parent)
+                                    })
+                                })
+                            })
+                            .ok()
+                            .flatten();
+
                         let entry_id = entry.to_proto();
-                        menu = menu.separator().entry(
-                            "Reveal In Project Panel",
-                            Some(Box::new(RevealInProjectPanel {
-                                entry_id: Some(entry_id),
-                            })),
-                            cx.handler_for(&pane, move |pane, cx| {
-                                pane.project.update(cx, |_, cx| {
-                                    cx.emit(project::Event::RevealInProjectPanel(
-                                        ProjectEntryId::from_proto(entry_id),
-                                    ))
-                                });
-                            }),
-                        );
+                        menu = menu
+                            .separator()
+                            .entry(
+                                "Reveal In Project Panel",
+                                Some(Box::new(RevealInProjectPanel {
+                                    entry_id: Some(entry_id),
+                                })),
+                                cx.handler_for(&pane, move |pane, cx| {
+                                    pane.project.update(cx, |_, cx| {
+                                        cx.emit(project::Event::RevealInProjectPanel(
+                                            ProjectEntryId::from_proto(entry_id),
+                                        ))
+                                    });
+                                }),
+                            )
+                            .when_some(parent_abs_path, |menu, abs_path| {
+                                menu.entry(
+                                    "Open in Terminal",
+                                    Some(Box::new(OpenInTerminal)),
+                                    cx.handler_for(&pane, move |_, cx| {
+                                        cx.dispatch_action(
+                                            OpenTerminal {
+                                                working_directory: abs_path.clone(),
+                                            }
+                                            .boxed_clone(),
+                                        );
+                                    }),
+                                )
+                            });
                     }
                 }