Improve cmd-click in terminal to find more paths (#26174)

Kirill Bulatov created

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

Reworks the way cmd-click is handled:

* first, all worktree entries are checked for existence

This allows more fine-grained lookup of entries that are in the
worktree, but their path in the terminal is not "full": in case neither
`cwd` no worktree's root + that temrinal paths form a valid path
(https://github.com/zed-industries/zed/issues/25701)

The worktrees are sorted by "the most close to cwd first" so such files
are attempted to resolved in the most specific worktree.

This also fixes no cmd-click working in the remote ssh.

* second, only if the client is local, do the FS checks to find
non-indexed files

Release Notes:

- Improved cmd-click in terminal to find more paths

Change summary

Cargo.lock                                |   1 
crates/terminal_view/Cargo.toml           |   4 
crates/terminal_view/src/terminal_view.rs | 364 +++++++++++++-----------
3 files changed, 203 insertions(+), 166 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13610,6 +13610,7 @@ dependencies = [
  "gpui",
  "itertools 0.14.0",
  "language",
+ "log",
  "project",
  "rand 0.8.5",
  "schemars",

crates/terminal_view/Cargo.toml 🔗

@@ -27,6 +27,7 @@ futures.workspace = true
 gpui.workspace = true
 itertools.workspace = true
 language.workspace = true
+log.workspace = true
 project.workspace = true
 task.workspace = true
 schemars.workspace = true
@@ -50,3 +51,6 @@ gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 rand.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
+
+[package.metadata.cargo-machete]
+ignored = ["log"]

crates/terminal_view/src/terminal_view.rs 🔗

@@ -4,16 +4,15 @@ pub mod terminal_panel;
 pub mod terminal_scrollbar;
 pub mod terminal_tab_tooltip;
 
-use collections::HashSet;
 use editor::{actions::SelectAll, scroll::ScrollbarAutoHide, Editor, EditorSettings};
-use futures::{stream::FuturesUnordered, StreamExt};
 use gpui::{
     anchored, deferred, div, impl_actions, AnyElement, App, DismissEvent, Entity, EventEmitter,
     FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent,
     Pixels, Render, ScrollWheelEvent, Stateful, Styled, Subscription, Task, WeakEntity,
 };
+use itertools::Itertools;
 use persistence::TERMINAL_DB;
-use project::{search::SearchQuery, terminals::TerminalKind, Fs, Metadata, Project};
+use project::{search::SearchQuery, terminals::TerminalKind, Entry, Metadata, Project};
 use schemars::JsonSchema;
 use terminal::{
     alacritty_terminal::{
@@ -32,10 +31,7 @@ use terminal_tab_tooltip::TerminalTooltip;
 use ui::{
     h_flex, prelude::*, ContextMenu, Icon, IconName, Label, Scrollbar, ScrollbarState, Tooltip,
 };
-use util::{
-    paths::{PathWithPosition, SanitizedPath},
-    ResultExt,
-};
+use util::{debug_panic, paths::PathWithPosition, ResultExt};
 use workspace::{
     item::{
         BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent,
@@ -67,7 +63,7 @@ const REGEX_SPECIAL_CHARS: &[char] = &[
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
 
-const GIT_DIFF_PATH_PREFIXES: &[char] = &['a', 'b'];
+const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"];
 
 /// Event to transmit the scroll from the element to the view
 #[derive(Clone, Debug, PartialEq)]
@@ -876,20 +872,13 @@ fn subscribe_for_terminal_events(
                 this.can_navigate_to_selected_word = match maybe_navigation_target {
                     Some(MaybeNavigationTarget::Url(_)) => true,
                     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).is_empty()
-                        } else {
-                            false
-                        }
+                        let valid_files_to_open_task = possible_open_target(
+                            &workspace,
+                            &path_like_target.terminal_dir,
+                            &path_like_target.maybe_path,
+                            cx,
+                        );
+                        smol::block_on(valid_files_to_open_task).is_some()
                     }
                     None => false,
                 };
@@ -904,21 +893,11 @@ fn subscribe_for_terminal_events(
                         return;
                     }
                     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_in(window, |terminal_view, mut cx| async move {
-                        let valid_files_to_open = terminal_view
+                        let open_target = terminal_view
                             .update(&mut cx, |_, cx| {
-                                possible_open_targets(
-                                    fs,
+                                possible_open_target(
                                     &task_workspace,
                                     &path_like_target.terminal_dir,
                                     &path_like_target.maybe_path,
@@ -926,60 +905,60 @@ fn subscribe_for_terminal_events(
                                 )
                             })?
                             .await;
-                        let paths_to_open = valid_files_to_open
-                            .iter()
-                            .map(|(p, _)| p.path.clone())
-                            .collect();
-                        let opened_items = task_workspace
-                            .update_in(&mut cx, |workspace, window, cx| {
-                                workspace.open_paths(
-                                    paths_to_open,
-                                    OpenVisible::OnlyDirectories,
-                                    None,
-                                    window,
-                                    cx,
-                                )
-                            })
-                            .context("workspace update")?
-                            .await;
+                        if let Some((path_to_open, open_target)) = open_target {
+                            let opened_items = task_workspace
+                                .update_in(&mut cx, |workspace, window, cx| {
+                                    workspace.open_paths(
+                                        vec![path_to_open.path.clone()],
+                                        OpenVisible::OnlyDirectories,
+                                        None,
+                                        window,
+                                        cx,
+                                    )
+                                })
+                                .context("workspace update")?
+                                .await;
+                            if opened_items.len() != 1 {
+                                debug_panic!(
+                                    "Received {} items for one path {path_to_open:?}",
+                                    opened_items.len(),
+                                );
+                            }
 
-                        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.downcast::<Editor>() {
-                                        active_editor
-                                            .downgrade()
-                                            .update_in(&mut cx, |editor, window, cx| {
-                                                editor.go_to_singleton_buffer_point(
-                                                    language::Point::new(
-                                                        row.saturating_sub(1),
-                                                        col.saturating_sub(1),
-                                                    ),
-                                                    window,
-                                                    cx,
-                                                )
-                                            })
-                                            .log_err();
+                            if let Some(opened_item) = opened_items.first() {
+                                if open_target.is_file() {
+                                    if let Some(Ok(opened_item)) = opened_item {
+                                        if let Some(row) = path_to_open.row {
+                                            let col = path_to_open.column.unwrap_or(0);
+                                            if let Some(active_editor) =
+                                                opened_item.downcast::<Editor>()
+                                            {
+                                                active_editor
+                                                    .downgrade()
+                                                    .update_in(&mut cx, |editor, window, cx| {
+                                                        editor.go_to_singleton_buffer_point(
+                                                            language::Point::new(
+                                                                row.saturating_sub(1),
+                                                                col.saturating_sub(1),
+                                                            ),
+                                                            window,
+                                                            cx,
+                                                        )
+                                                    })
+                                                    .log_err();
+                                            }
+                                        }
                                     }
+                                } else if open_target.is_dir() {
+                                    task_workspace.update(&mut cx, |workspace, cx| {
+                                        workspace.project().update(cx, |_, cx| {
+                                            cx.emit(project::Event::ActivateProjectPanel);
+                                        })
+                                    })?;
                                 }
                             }
                         }
 
-                        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)
@@ -996,105 +975,158 @@ fn subscribe_for_terminal_events(
     vec![terminal_subscription, terminal_events_subscription]
 }
 
-fn possible_open_paths_metadata(
-    fs: Arc<dyn Fs>,
-    row: Option<u32>,
-    column: Option<u32>,
-    potential_paths: HashSet<PathBuf>,
+#[derive(Debug, Clone)]
+enum OpenTarget {
+    Worktree(Entry),
+    File(Metadata),
+}
+
+impl OpenTarget {
+    fn is_file(&self) -> bool {
+        match self {
+            OpenTarget::Worktree(entry) => entry.is_file(),
+            OpenTarget::File(metadata) => !metadata.is_dir,
+        }
+    }
+
+    fn is_dir(&self) -> bool {
+        match self {
+            OpenTarget::Worktree(entry) => entry.is_dir(),
+            OpenTarget::File(metadata) => metadata.is_dir,
+        }
+    }
+}
+
+fn possible_open_target(
+    workspace: &WeakEntity<Workspace>,
+    cwd: &Option<PathBuf>,
+    maybe_path: &str,
     cx: &mut Context<TerminalView>,
-) -> Task<Vec<(PathWithPosition, Metadata)>> {
-    cx.background_spawn(async move {
-        let mut canonical_paths = HashSet::default();
-        for path in potential_paths {
-            if let Ok(canonical) = fs.canonicalize(&path).await {
-                let sanitized = SanitizedPath::from(canonical);
-                canonical_paths.insert(sanitized.as_path().to_path_buf());
-            } else {
-                canonical_paths.insert(path);
-            }
+) -> Task<Option<(PathWithPosition, OpenTarget)>> {
+    let Some(workspace) = workspace.upgrade() else {
+        return Task::ready(None);
+    };
+    // We have to check for both paths, as on Unix, certain paths with positions are valid file paths too.
+    // We can be on FS remote part, without real FS, so cannot canonicalize or check for existence the path right away.
+    let mut potential_paths = Vec::new();
+    let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path));
+    let path_with_position = PathWithPosition::parse_str(maybe_path);
+    for prefix_str in GIT_DIFF_PATH_PREFIXES {
+        if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() {
+            potential_paths.push(PathWithPosition {
+                path: stripped.to_owned(),
+                row: original_path.row,
+                column: original_path.column,
+            });
+        }
+        if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() {
+            potential_paths.push(PathWithPosition {
+                path: stripped.to_owned(),
+                row: path_with_position.row,
+                column: path_with_position.column,
+            });
         }
+    }
+    potential_paths.insert(0, original_path);
+    potential_paths.insert(1, path_with_position);
 
-        let mut paths_with_metadata = Vec::with_capacity(canonical_paths.len());
+    for worktree in workspace.read(cx).worktrees(cx).sorted_by_key(|worktree| {
+        let worktree_root = worktree.read(cx).abs_path();
+        match cwd
+            .as_ref()
+            .and_then(|cwd| worktree_root.strip_prefix(cwd).ok())
+        {
+            Some(cwd_child) => cwd_child.components().count(),
+            None => usize::MAX,
+        }
+    }) {
+        let worktree_root = worktree.read(cx).abs_path();
+        let paths_to_check = potential_paths
+            .iter()
+            .map(|path_with_position| PathWithPosition {
+                path: path_with_position
+                    .path
+                    .strip_prefix(&worktree_root)
+                    .unwrap_or(&path_with_position.path)
+                    .to_owned(),
+                row: path_with_position.row,
+                column: path_with_position.column,
+            })
+            .collect::<Vec<_>>();
 
-        let mut fetch_metadata_tasks = canonical_paths
-            .into_iter()
-            .map(|potential_path| async {
-                let metadata = fs.metadata(&potential_path).await.ok().flatten();
-                (
+        let mut traversal = worktree
+            .read(cx)
+            .traverse_from_path(true, true, false, "".as_ref());
+        while let Some(entry) = traversal.next() {
+            if let Some(path_in_worktree) = paths_to_check
+                .iter()
+                .find(|path_to_check| entry.path.ends_with(&path_to_check.path))
+            {
+                return Task::ready(Some((
                     PathWithPosition {
-                        path: potential_path,
-                        row,
-                        column,
+                        path: worktree_root.join(&entry.path),
+                        row: path_in_worktree.row,
+                        column: path_in_worktree.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));
+                    OpenTarget::Worktree(entry.clone()),
+                )));
             }
         }
+    }
 
-        paths_with_metadata
-    })
-}
-
-fn possible_open_targets(
-    fs: Arc<dyn Fs>,
-    workspace: &WeakEntity<Workspace>,
-    cwd: &Option<PathBuf>,
-    maybe_path: &String,
+    if !workspace.read(cx).project().read(cx).is_local() {
+        return Task::ready(None);
+    }
+    let fs = workspace.read(cx).project().read(cx).fs().clone();
 
-    cx: &mut Context<TerminalView>,
-) -> Task<Vec<(PathWithPosition, Metadata)>> {
-    let path_position = PathWithPosition::parse_str(maybe_path.as_str());
-    let row = path_position.row;
-    let column = path_position.column;
-    let maybe_path = path_position.path;
-
-    let potential_paths = if maybe_path.is_absolute() {
-        HashSet::from_iter([maybe_path])
-    } else if maybe_path.starts_with("~") {
-        maybe_path
-            .strip_prefix("~")
-            .ok()
-            .and_then(|maybe_path| Some(dirs::home_dir()?.join(maybe_path)))
-            .map_or_else(HashSet::default, |p| HashSet::from_iter([p]))
-    } else {
-        let mut potential_cwd_and_workspace_paths = HashSet::default();
-        if let Some(cwd) = cwd {
-            let abs_path = Path::join(cwd, &maybe_path);
-            potential_cwd_and_workspace_paths.insert(abs_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))
+    let paths_to_check = potential_paths
+        .into_iter()
+        .flat_map(|path_to_check| {
+            let mut paths_to_check = Vec::new();
+            let maybe_path = &path_to_check.path;
+            if maybe_path.starts_with("~") {
+                if let Some(home_path) =
+                    maybe_path
+                        .strip_prefix("~")
+                        .ok()
+                        .and_then(|stripped_maybe_path| {
+                            Some(dirs::home_dir()?.join(stripped_maybe_path))
+                        })
                 {
-                    potential_cwd_and_workspace_paths.insert(potential_worktree_path);
+                    paths_to_check.push(PathWithPosition {
+                        path: home_path,
+                        row: path_to_check.row,
+                        column: path_to_check.column,
+                    });
                 }
-
-                for prefix in GIT_DIFF_PATH_PREFIXES {
-                    let prefix_str = &prefix.to_string();
-                    if maybe_path.starts_with(prefix_str) {
-                        let stripped = maybe_path.strip_prefix(prefix_str).unwrap_or(&maybe_path);
-                        for potential_worktree_path in workspace
-                            .worktrees(cx)
-                            .map(|worktree| worktree.read(cx).abs_path().join(&stripped))
-                        {
-                            potential_cwd_and_workspace_paths.insert(potential_worktree_path);
-                        }
+            } else {
+                paths_to_check.push(PathWithPosition {
+                    path: maybe_path.clone(),
+                    row: path_to_check.row,
+                    column: path_to_check.column,
+                });
+                if maybe_path.is_relative() {
+                    if let Some(cwd) = &cwd {
+                        paths_to_check.push(PathWithPosition {
+                            path: cwd.join(maybe_path),
+                            row: path_to_check.row,
+                            column: path_to_check.column,
+                        });
                     }
                 }
-            });
-        }
-        potential_cwd_and_workspace_paths
-    };
+            }
+            paths_to_check
+        })
+        .collect::<Vec<_>>();
 
-    possible_open_paths_metadata(fs, row, column, potential_paths, cx)
+    cx.background_spawn(async move {
+        for path_to_check in paths_to_check {
+            if let Some(metadata) = fs.metadata(&path_to_check.path).await.ok().flatten() {
+                return Some((path_to_check, OpenTarget::File(metadata)));
+            }
+        }
+        None
+    })
 }
 
 fn regex_to_literal(regex: &str) -> String {