Add support for relative terminal links (#7303)

Robin PfΓ€ffle and Kirill created

Allow opening file paths relative to terminal's cwd


https://github.com/zed-industries/zed/assets/67913738/413a1107-541e-4c25-ae7c-cbe45469d452


Release Notes:

- Added support for opening file paths relative to terminal's cwd
([#7144](https://github.com/zed-industries/zed/issues/7144)).

---------

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

Change summary

Cargo.lock                                |   1 
crates/terminal/src/terminal.rs           |  27 ++
crates/terminal_view/Cargo.toml           |   1 
crates/terminal_view/src/terminal_view.rs | 216 ++++++++++++++++--------
crates/util/src/paths.rs                  |   2 
5 files changed, 168 insertions(+), 79 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -8133,6 +8133,7 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "client",
+ "collections",
  "db",
  "dirs 4.0.0",
  "editor",

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

@@ -86,6 +86,15 @@ pub enum Event {
     Open(MaybeNavigationTarget),
 }
 
+#[derive(Clone, Debug)]
+pub struct PathLikeTarget {
+    /// File system path, absolute or relative, existing or not.
+    /// Might have line and column number(s) attached as `file.rs:1:23`
+    pub maybe_path: String,
+    /// Current working directory of the terminal
+    pub terminal_dir: Option<PathBuf>,
+}
+
 /// A string inside terminal, potentially useful as a URI that can be opened.
 #[derive(Clone, Debug)]
 pub enum MaybeNavigationTarget {
@@ -93,7 +102,7 @@ pub enum MaybeNavigationTarget {
     Url(String),
     /// File system path, absolute or relative, existing or not.
     /// Might have line and column number(s) attached as `file.rs:1:23`
-    PathLike(String),
+    PathLike(PathLikeTarget),
 }
 
 #[derive(Clone)]
@@ -626,6 +635,12 @@ impl Terminal {
         }
     }
 
+    fn get_cwd(&self) -> Option<PathBuf> {
+        self.foreground_process_info
+            .as_ref()
+            .map(|info| info.cwd.clone())
+    }
+
     ///Takes events from Alacritty and translates them to behavior on this view
     fn process_terminal_event(
         &mut self,
@@ -800,7 +815,10 @@ impl Terminal {
                             let target = if is_url {
                                 MaybeNavigationTarget::Url(maybe_url_or_path)
                             } else {
-                                MaybeNavigationTarget::PathLike(maybe_url_or_path)
+                                MaybeNavigationTarget::PathLike(PathLikeTarget {
+                                    maybe_path: maybe_url_or_path,
+                                    terminal_dir: self.get_cwd(),
+                                })
                             };
                             cx.emit(Event::Open(target));
                         } else {
@@ -852,7 +870,10 @@ impl Terminal {
         let navigation_target = if is_url {
             MaybeNavigationTarget::Url(word)
         } else {
-            MaybeNavigationTarget::PathLike(word)
+            MaybeNavigationTarget::PathLike(PathLikeTarget {
+                maybe_path: word,
+                terminal_dir: self.get_cwd(),
+            })
         };
         cx.emit(Event::NewNavigationTarget(Some(navigation_target)));
     }

crates/terminal_view/Cargo.toml πŸ”—

@@ -12,6 +12,7 @@ doctest = false
 [dependencies]
 anyhow.workspace = true
 db = { path = "../db" }
+collections = { path = "../collections" }
 dirs = "4.0.0"
 editor = { path = "../editor" }
 futures.workspace = true

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

@@ -2,7 +2,9 @@ mod persistence;
 pub mod terminal_element;
 pub mod terminal_panel;
 
+use collections::HashSet;
 use editor::{scroll::Autoscroll, Editor};
+use futures::{stream::FuturesUnordered, StreamExt};
 use gpui::{
     div, impl_actions, overlay, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
     FocusableView, KeyContext, KeyDownEvent, Keystroke, Model, MouseButton, MouseDownEvent, Pixels,
@@ -10,7 +12,7 @@ use gpui::{
 };
 use language::Bias;
 use persistence::TERMINAL_DB;
-use project::{search::SearchQuery, LocalWorktree, Project};
+use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project};
 use terminal::{
     alacritty_terminal::{
         index::Point,
@@ -177,8 +179,21 @@ impl TerminalView {
             Event::NewNavigationTarget(maybe_navigation_target) => {
                 this.can_navigate_to_selected_word = match maybe_navigation_target {
                     Some(MaybeNavigationTarget::Url(_)) => true,
-                    Some(MaybeNavigationTarget::PathLike(maybe_path)) => {
-                        !possible_open_targets(&workspace, maybe_path, cx).is_empty()
+                    Some(MaybeNavigationTarget::PathLike(path_like_target)) => {
+                        if let Ok(fs) = workspace.update(cx, |workspace, cx| {
+                            workspace.project().read(cx).fs().clone()
+                        }) {
+                            let valid_files_to_open_task = possible_open_targets(
+                                fs,
+                                &workspace,
+                                &path_like_target.terminal_dir,
+                                &path_like_target.maybe_path,
+                                cx,
+                            );
+                            smol::block_on(valid_files_to_open_task).len() > 0
+                        } else {
+                            false
+                        }
                     }
                     None => false,
                 }
@@ -187,57 +202,60 @@ impl TerminalView {
             Event::Open(maybe_navigation_target) => match maybe_navigation_target {
                 MaybeNavigationTarget::Url(url) => cx.open_url(url),
 
-                MaybeNavigationTarget::PathLike(maybe_path) => {
+                MaybeNavigationTarget::PathLike(path_like_target) => {
                     if !this.can_navigate_to_selected_word {
                         return;
                     }
-                    let potential_abs_paths = possible_open_targets(&workspace, maybe_path, cx);
-                    if let Some(path) = potential_abs_paths.into_iter().next() {
-                        let task_workspace = workspace.clone();
-                        cx.spawn(|_, mut cx| async move {
-                            let fs = task_workspace.update(&mut cx, |workspace, cx| {
-                                workspace.project().read(cx).fs().clone()
-                            })?;
-                            let is_dir = fs
-                                .metadata(&path.path_like)
-                                .await?
-                                .with_context(|| {
-                                    format!("Missing metadata for file {:?}", path.path_like)
-                                })?
-                                .is_dir;
-                            let opened_items = task_workspace
-                                .update(&mut cx, |workspace, cx| {
-                                    workspace.open_paths(
-                                        vec![path.path_like],
-                                        OpenVisible::OnlyDirectories,
-                                        None,
-                                        cx,
-                                    )
-                                })
-                                .context("workspace update")?
-                                .await;
-                            anyhow::ensure!(
-                                opened_items.len() == 1,
-                                "For a single path open, expected single opened item"
-                            );
-                            let opened_item = opened_items
-                                .into_iter()
-                                .next()
-                                .unwrap()
-                                .transpose()
-                                .context("path open")?;
-                            if is_dir {
-                                task_workspace.update(&mut cx, |workspace, cx| {
-                                    workspace.project().update(cx, |_, cx| {
-                                        cx.emit(project::Event::ActivateProjectPanel);
-                                    })
-                                })?;
-                            } else {
+                    let task_workspace = workspace.clone();
+                    let Some(fs) = workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.project().read(cx).fs().clone()
+                        })
+                        .ok()
+                    else {
+                        return;
+                    };
+
+                    let path_like_target = path_like_target.clone();
+                    cx.spawn(|terminal_view, mut cx| async move {
+                        let valid_files_to_open = terminal_view
+                            .update(&mut cx, |_, cx| {
+                                possible_open_targets(
+                                    fs,
+                                    &task_workspace,
+                                    &path_like_target.terminal_dir,
+                                    &path_like_target.maybe_path,
+                                    cx,
+                                )
+                            })?
+                            .await;
+                        let paths_to_open = valid_files_to_open
+                            .iter()
+                            .map(|(p, _)| p.path_like.clone())
+                            .collect();
+                        let opened_items = task_workspace
+                            .update(&mut cx, |workspace, cx| {
+                                workspace.open_paths(
+                                    paths_to_open,
+                                    OpenVisible::OnlyDirectories,
+                                    None,
+                                    cx,
+                                )
+                            })
+                            .context("workspace update")?
+                            .await;
+
+                        let mut has_dirs = false;
+                        for ((path, metadata), opened_item) in valid_files_to_open
+                            .into_iter()
+                            .zip(opened_items.into_iter())
+                        {
+                            if metadata.is_dir {
+                                has_dirs = true;
+                            } else if let Some(Ok(opened_item)) = opened_item {
                                 if let Some(row) = path.row {
                                     let col = path.column.unwrap_or(0);
-                                    if let Some(active_editor) =
-                                        opened_item.and_then(|item| item.downcast::<Editor>())
-                                    {
+                                    if let Some(active_editor) = opened_item.downcast::<Editor>() {
                                         active_editor
                                             .downgrade()
                                             .update(&mut cx, |editor, cx| {
@@ -259,10 +277,19 @@ impl TerminalView {
                                     }
                                 }
                             }
-                            anyhow::Ok(())
-                        })
-                        .detach_and_log_err(cx);
-                    }
+                        }
+
+                        if has_dirs {
+                            task_workspace.update(&mut cx, |workspace, cx| {
+                                workspace.project().update(cx, |_, cx| {
+                                    cx.emit(project::Event::ActivateProjectPanel);
+                                })
+                            })?;
+                        }
+
+                        anyhow::Ok(())
+                    })
+                    .detach_and_log_err(cx)
                 }
             },
             Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs),
@@ -554,48 +581,87 @@ impl TerminalView {
     }
 }
 
+fn possible_open_paths_metadata(
+    fs: Arc<dyn Fs>,
+    row: Option<u32>,
+    column: Option<u32>,
+    potential_paths: HashSet<PathBuf>,
+    cx: &mut ViewContext<TerminalView>,
+) -> Task<Vec<(PathLikeWithPosition<PathBuf>, Metadata)>> {
+    cx.background_executor().spawn(async move {
+        let mut paths_with_metadata = Vec::with_capacity(potential_paths.len());
+
+        let mut fetch_metadata_tasks = potential_paths
+            .into_iter()
+            .map(|potential_path| async {
+                let metadata = fs.metadata(&potential_path).await.ok().flatten();
+                (
+                    PathLikeWithPosition {
+                        path_like: potential_path,
+                        row,
+                        column,
+                    },
+                    metadata,
+                )
+            })
+            .collect::<FuturesUnordered<_>>();
+
+        while let Some((path, metadata)) = fetch_metadata_tasks.next().await {
+            if let Some(metadata) = metadata {
+                paths_with_metadata.push((path, metadata));
+            }
+        }
+
+        paths_with_metadata
+    })
+}
+
 fn possible_open_targets(
+    fs: Arc<dyn Fs>,
     workspace: &WeakView<Workspace>,
+    cwd: &Option<PathBuf>,
     maybe_path: &String,
-    cx: &mut ViewContext<'_, TerminalView>,
-) -> Vec<PathLikeWithPosition<PathBuf>> {
+    cx: &mut ViewContext<TerminalView>,
+) -> Task<Vec<(PathLikeWithPosition<PathBuf>, Metadata)>> {
     let path_like = PathLikeWithPosition::parse_str(maybe_path.as_str(), |path_str| {
         Ok::<_, std::convert::Infallible>(Path::new(path_str).to_path_buf())
     })
     .expect("infallible");
+    let row = path_like.row;
+    let column = path_like.column;
     let maybe_path = path_like.path_like;
     let potential_abs_paths = if maybe_path.is_absolute() {
-        vec![maybe_path]
+        HashSet::from_iter([maybe_path])
     } else if maybe_path.starts_with("~") {
         if let Some(abs_path) = maybe_path
             .strip_prefix("~")
             .ok()
             .and_then(|maybe_path| Some(dirs::home_dir()?.join(maybe_path)))
         {
-            vec![abs_path]
+            HashSet::from_iter([abs_path])
         } else {
-            Vec::new()
+            HashSet::default()
         }
-    } else if let Some(workspace) = workspace.upgrade() {
-        workspace.update(cx, |workspace, cx| {
-            workspace
-                .worktrees(cx)
-                .map(|worktree| worktree.read(cx).abs_path().join(&maybe_path))
-                .collect()
-        })
     } else {
-        Vec::new()
+        // First check cwd and then workspace
+        let mut potential_cwd_and_workspace_paths = HashSet::default();
+        if let Some(cwd) = cwd {
+            potential_cwd_and_workspace_paths.insert(Path::join(cwd, &maybe_path));
+        }
+        if let Some(workspace) = workspace.upgrade() {
+            workspace.update(cx, |workspace, cx| {
+                for potential_worktree_path in workspace
+                    .worktrees(cx)
+                    .map(|worktree| worktree.read(cx).abs_path().join(&maybe_path))
+                {
+                    potential_cwd_and_workspace_paths.insert(potential_worktree_path);
+                }
+            });
+        }
+        potential_cwd_and_workspace_paths
     };
 
-    potential_abs_paths
-        .into_iter()
-        .filter(|path| path.exists())
-        .map(|path| PathLikeWithPosition {
-            path_like: path,
-            row: path_like.row,
-            column: path_like.column,
-        })
-        .collect()
+    possible_open_paths_metadata(fs, row, column, potential_abs_paths, cx)
 }
 
 pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {

crates/util/src/paths.rs πŸ”—

@@ -121,7 +121,7 @@ pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
 
 /// A representation of a path-like string with optional row and column numbers.
 /// Matching values example: `te`, `test.rs:22`, `te:22:5`, etc.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
 pub struct PathLikeWithPosition<P> {
     pub path_like: P,
     pub row: Option<u32>,