From c1e749906febe241a3138280fafcc3ff3fca7416 Mon Sep 17 00:00:00 2001 From: Dave Waggoner Date: Thu, 21 Aug 2025 11:41:32 -0700 Subject: [PATCH] Add terminal view path like target tests (#35422) Part of - #28238 This PR refactors `Event::NewNavigationTarget` and `Event::Open` handling of `PathLikeTarget` and associated code in `terminal_view.rs` into its own file, `terminal_path_like_target.rs` for improved testability, and adds tests which cover cases from: - #28339 - #28407 - #33498 - #34027 - #34078 Release Notes: - N/A --- .../src/terminal_path_like_target.rs | 825 ++++++++++++++++++ crates/terminal_view/src/terminal_view.rs | 370 +------- 2 files changed, 844 insertions(+), 351 deletions(-) create mode 100644 crates/terminal_view/src/terminal_path_like_target.rs diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs new file mode 100644 index 0000000000..e20df7f001 --- /dev/null +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -0,0 +1,825 @@ +use super::{HoverTarget, HoveredWord, TerminalView}; +use anyhow::{Context as _, Result}; +use editor::Editor; +use gpui::{App, AppContext, Context, Task, WeakEntity, Window}; +use itertools::Itertools; +use project::{Entry, Metadata}; +use std::path::PathBuf; +use terminal::PathLikeTarget; +use util::{ResultExt, debug_panic, paths::PathWithPosition}; +use workspace::{OpenOptions, OpenVisible, Workspace}; + +#[derive(Debug, Clone)] +enum OpenTarget { + Worktree(PathWithPosition, Entry), + File(PathWithPosition, 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 path(&self) -> &PathWithPosition { + match self { + OpenTarget::Worktree(path, _) => path, + OpenTarget::File(path, _) => path, + } + } +} + +pub(super) fn hover_path_like_target( + workspace: &WeakEntity, + hovered_word: HoveredWord, + path_like_target: &PathLikeTarget, + cx: &mut Context, +) -> Task<()> { + let file_to_open_task = possible_open_target(workspace, path_like_target, cx); + cx.spawn(async move |terminal_view, cx| { + let file_to_open = file_to_open_task.await; + terminal_view + .update(cx, |terminal_view, _| match file_to_open { + Some(OpenTarget::File(path, _) | OpenTarget::Worktree(path, _)) => { + terminal_view.hover = Some(HoverTarget { + tooltip: path.to_string(|path| path.to_string_lossy().to_string()), + hovered_word, + }); + } + None => { + terminal_view.hover = None; + } + }) + .ok(); + }) +} + +fn possible_open_target( + workspace: &WeakEntity, + path_like_target: &PathLikeTarget, + cx: &App, +) -> Task> { + 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 cwd = path_like_target.terminal_dir.as_ref(); + let maybe_path = &path_like_target.maybe_path; + let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path)); + let path_with_position = PathWithPosition::parse_str(maybe_path); + let worktree_candidates = workspace + .read(cx) + .worktrees(cx) + .sorted_by_key(|worktree| { + let worktree_root = worktree.read(cx).abs_path(); + match cwd.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) { + Some(cwd_child) => cwd_child.components().count(), + None => usize::MAX, + } + }) + .collect::>(); + // Since we do not check paths via FS and joining, we need to strip off potential `./`, `a/`, `b/` prefixes out of it. + const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; + for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) { + 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, + }); + } + } + + let insert_both_paths = original_path != path_with_position; + potential_paths.insert(0, original_path); + if insert_both_paths { + potential_paths.insert(1, path_with_position); + } + + // If we won't find paths "easily", we can traverse the entire worktree to look what ends with the potential path suffix. + // That will be slow, though, so do the fast checks first. + let mut worktree_paths_to_check = Vec::new(); + for worktree in &worktree_candidates { + let worktree_root = worktree.read(cx).abs_path(); + let mut paths_to_check = Vec::with_capacity(potential_paths.len()); + + for path_with_position in &potential_paths { + let path_to_check = if worktree_root.ends_with(&path_with_position.path) { + let root_path_with_position = PathWithPosition { + path: worktree_root.to_path_buf(), + row: path_with_position.row, + column: path_with_position.column, + }; + match worktree.read(cx).root_entry() { + Some(root_entry) => { + return Task::ready(Some(OpenTarget::Worktree( + root_path_with_position, + root_entry.clone(), + ))); + } + None => root_path_with_position, + } + } else { + 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, + } + }; + + if path_to_check.path.is_relative() + && let Some(entry) = worktree.read(cx).entry_for_path(&path_to_check.path) + { + return Task::ready(Some(OpenTarget::Worktree( + PathWithPosition { + path: worktree_root.join(&entry.path), + row: path_to_check.row, + column: path_to_check.column, + }, + entry.clone(), + ))); + } + + paths_to_check.push(path_to_check); + } + + if !paths_to_check.is_empty() { + worktree_paths_to_check.push((worktree.clone(), paths_to_check)); + } + } + + // Before entire worktree traversal(s), make an attempt to do FS checks if available. + let fs_paths_to_check = if workspace.read(cx).project().read(cx).is_local() { + 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)) + }) + { + paths_to_check.push(PathWithPosition { + path: home_path, + row: path_to_check.row, + column: path_to_check.column, + }); + } + } 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, + }); + } + for worktree in &worktree_candidates { + paths_to_check.push(PathWithPosition { + path: worktree.read(cx).abs_path().join(maybe_path), + row: path_to_check.row, + column: path_to_check.column, + }); + } + } + } + paths_to_check + }) + .collect() + } else { + Vec::new() + }; + + let worktree_check_task = cx.spawn(async move |cx| { + for (worktree, worktree_paths_to_check) in worktree_paths_to_check { + let found_entry = worktree + .update(cx, |worktree, _| { + let worktree_root = worktree.abs_path(); + let traversal = worktree.traverse_from_path(true, true, false, "".as_ref()); + for entry in traversal { + if let Some(path_in_worktree) = worktree_paths_to_check + .iter() + .find(|path_to_check| entry.path.ends_with(&path_to_check.path)) + { + return Some(OpenTarget::Worktree( + PathWithPosition { + path: worktree_root.join(&entry.path), + row: path_in_worktree.row, + column: path_in_worktree.column, + }, + entry.clone(), + )); + } + } + None + }) + .ok()?; + if let Some(found_entry) = found_entry { + return Some(found_entry); + } + } + None + }); + + let fs = workspace.read(cx).project().read(cx).fs().clone(); + cx.background_spawn(async move { + for mut path_to_check in fs_paths_to_check { + if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() + && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() + { + path_to_check.path = fs_path_to_check; + return Some(OpenTarget::File(path_to_check, metadata)); + } + } + + worktree_check_task.await + }) +} + +pub(super) fn open_path_like_target( + workspace: &WeakEntity, + terminal_view: &mut TerminalView, + path_like_target: &PathLikeTarget, + window: &mut Window, + cx: &mut Context, +) { + possibly_open_target(workspace, terminal_view, path_like_target, window, cx) + .detach_and_log_err(cx) +} + +fn possibly_open_target( + workspace: &WeakEntity, + terminal_view: &mut TerminalView, + path_like_target: &PathLikeTarget, + window: &mut Window, + cx: &mut Context, +) -> Task>> { + if terminal_view.hover.is_none() { + return Task::ready(Ok(None)); + } + let workspace = workspace.clone(); + let path_like_target = path_like_target.clone(); + cx.spawn_in(window, async move |terminal_view, cx| { + let Some(open_target) = terminal_view + .update(cx, |_, cx| { + possible_open_target(&workspace, &path_like_target, cx) + })? + .await + else { + return Ok(None); + }; + + let path_to_open = open_target.path(); + let opened_items = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_paths( + vec![path_to_open.path.clone()], + OpenOptions { + visible: Some(OpenVisible::OnlyDirectories), + ..Default::default() + }, + 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(), + ); + } + + 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::() { + active_editor + .downgrade() + .update_in(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(); + } + } + return Ok(Some(open_target)); + } + } else if open_target.is_dir() { + workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |_, cx| { + cx.emit(project::Event::ActivateProjectPanel); + }) + })?; + return Ok(Some(open_target)); + } + } + Ok(None) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use project::{Project, terminals::TerminalKind}; + use serde_json::json; + use std::path::{Path, PathBuf}; + use terminal::{HoveredWord, alacritty_terminal::index::Point as AlacPoint}; + use util::path; + use workspace::AppState; + + async fn init_test( + app_cx: &mut TestAppContext, + trees: impl IntoIterator, + worktree_roots: impl IntoIterator, + ) -> impl AsyncFnMut(HoveredWord, PathLikeTarget) -> (Option, Option) + { + let fs = app_cx.update(AppState::test).fs.as_fake().clone(); + + app_cx.update(|cx| { + terminal::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + Project::init_settings(cx); + language::init(cx); + editor::init(cx); + }); + + for (path, tree) in trees { + fs.insert_tree(path, tree).await; + } + + let project = Project::test( + fs.clone(), + worktree_roots + .into_iter() + .map(Path::new) + .collect::>(), + app_cx, + ) + .await; + + let (workspace, cx) = + app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let terminal = project + .update(cx, |project, cx| { + project.create_terminal(TerminalKind::Shell(None), cx) + }) + .await + .expect("Failed to create a terminal"); + + let workspace_a = workspace.clone(); + let (terminal_view, cx) = app_cx.add_window_view(|window, cx| { + TerminalView::new( + terminal, + workspace_a.downgrade(), + None, + project.downgrade(), + window, + cx, + ) + }); + + async move |hovered_word: HoveredWord, + path_like_target: PathLikeTarget| + -> (Option, Option) { + let workspace_a = workspace.clone(); + terminal_view + .update(cx, |_, cx| { + hover_path_like_target( + &workspace_a.downgrade(), + hovered_word, + &path_like_target, + cx, + ) + }) + .await; + + let hover_target = + terminal_view.read_with(cx, |terminal_view, _| terminal_view.hover.clone()); + + let open_target = terminal_view + .update_in(cx, |terminal_view, window, cx| { + possibly_open_target( + &workspace.downgrade(), + terminal_view, + &path_like_target, + window, + cx, + ) + }) + .await + .expect("Failed to possibly open target"); + + (hover_target, open_target) + } + } + + async fn test_path_like_simple( + test_path_like: &mut impl AsyncFnMut( + HoveredWord, + PathLikeTarget, + ) -> (Option, Option), + maybe_path: &str, + tooltip: &str, + terminal_dir: Option, + file: &str, + line: u32, + ) { + let (hover_target, open_target) = test_path_like( + HoveredWord { + word: maybe_path.to_string(), + word_match: AlacPoint::default()..=AlacPoint::default(), + id: 0, + }, + PathLikeTarget { + maybe_path: maybe_path.to_string(), + terminal_dir, + }, + ) + .await; + + let Some(hover_target) = hover_target else { + assert!( + hover_target.is_some(), + "Hover target should not be `None` at {file}:{line}:" + ); + return; + }; + + assert_eq!( + hover_target.tooltip, tooltip, + "Tooltip mismatch at {file}:{line}:" + ); + assert_eq!( + hover_target.hovered_word.word, maybe_path, + "Hovered word mismatch at {file}:{line}:" + ); + + let Some(open_target) = open_target else { + assert!( + open_target.is_some(), + "Open target should not be `None` at {file}:{line}:" + ); + return; + }; + + assert_eq!( + open_target.path().path, + Path::new(tooltip), + "Open target path mismatch at {file}:{line}:" + ); + } + + macro_rules! none_or_some { + () => { + None + }; + ($some:expr) => { + Some($some) + }; + } + + macro_rules! test_path_like { + ($test_path_like:expr, $maybe_path:literal, $tooltip:literal $(, $cwd:literal)?) => { + test_path_like_simple( + &mut $test_path_like, + path!($maybe_path), + path!($tooltip), + none_or_some!($($crate::PathBuf::from(path!($cwd)))?), + std::file!(), + std::line!(), + ) + .await + }; + } + + #[doc = "test_path_likes!(, , , { $(;)+ })"] + macro_rules! test_path_likes { + ($cx:expr, $trees:expr, $worktrees:expr, { $($tests:expr;)+ }) => { { + let mut test_path_like = init_test($cx, $trees, $worktrees).await; + #[doc ="test!(, , )"] + macro_rules! test { + ($maybe_path:literal, $tooltip:literal) => { + test_path_like!(test_path_like, $maybe_path, $tooltip) + }; + ($maybe_path:literal, $tooltip:literal, $cwd:literal) => { + test_path_like!(test_path_like, $maybe_path, $tooltip, $cwd) + } + } + $($tests);+ + } } + } + + #[gpui::test] + async fn one_folder_worktree(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/test"), + json!({ + "lib.rs": "", + "test.rs": "", + }), + )], + vec![path!("/test")], + { + test!("lib.rs", "/test/lib.rs"); + test!("test.rs", "/test/test.rs"); + } + ) + } + + #[gpui::test] + async fn mixed_worktrees(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "lib.rs": "", + "test.rs": "", + "file.txt": "", + }), + ), + ], + vec![path!("/file.txt"), path!("/test")], + { + test!("file.txt", "/file.txt", "/"); + test!("lib.rs", "/test/lib.rs", "/test"); + test!("test.rs", "/test/test.rs", "/test"); + test!("file.txt", "/test/file.txt", "/test"); + } + ) + } + + #[gpui::test] + async fn worktree_file_preferred(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "file.txt": "", + }), + ), + ], + vec![path!("/test")], + { + test!("file.txt", "/test/file.txt", "/test"); + } + ) + } + + mod issues { + use super::*; + + // https://github.com/zed-industries/zed/issues/28407 + #[gpui::test] + async fn issue_28407_siblings(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/dir1"), + json!({ + "dir 2": { + "C.py": "" + }, + "dir 3": { + "C.py": "" + }, + }), + )], + vec![path!("/dir1")], + { + test!("C.py", "/dir1/dir 2/C.py", "/dir1"); + test!("C.py", "/dir1/dir 2/C.py", "/dir1/dir 2"); + test!("C.py", "/dir1/dir 3/C.py", "/dir1/dir 3"); + } + ) + } + + // https://github.com/zed-industries/zed/issues/28407 + // See https://github.com/zed-industries/zed/issues/34027 + // See https://github.com/zed-industries/zed/issues/33498 + #[gpui::test] + #[should_panic(expected = "Tooltip mismatch")] + async fn issue_28407_nesting(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/project"), + json!({ + "lib": { + "src": { + "main.rs": "" + }, + }, + "src": { + "main.rs": "" + }, + }), + )], + vec![path!("/project")], + { + // Failing currently + test!("main.rs", "/project/src/main.rs", "/project"); + test!("main.rs", "/project/src/main.rs", "/project/src"); + test!("main.rs", "/project/lib/src/main.rs", "/project/lib"); + test!("main.rs", "/project/lib/src/main.rs", "/project/lib/src"); + + test!("src/main.rs", "/project/src/main.rs", "/project"); + test!("src/main.rs", "/project/src/main.rs", "/project/src"); + // Failing currently + test!("src/main.rs", "/project/lib/src/main.rs", "/project/lib"); + // Failing currently + test!( + "src/main.rs", + "/project/lib/src/main.rs", + "/project/lib/src" + ); + + test!("lib/src/main.rs", "/project/lib/src/main.rs", "/project"); + test!( + "lib/src/main.rs", + "/project/lib/src/main.rs", + "/project/src" + ); + test!( + "lib/src/main.rs", + "/project/lib/src/main.rs", + "/project/lib" + ); + test!( + "lib/src/main.rs", + "/project/lib/src/main.rs", + "/project/lib/src" + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/28339 + #[gpui::test] + async fn issue_28339(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/tmp"), + json!({ + "issue28339": { + "foo": { + "bar.txt": "" + }, + }, + }), + )], + vec![path!("/tmp")], + { + test!( + "foo/./bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test!( + "foo/../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test!( + "foo/..///foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test!( + "issue28339/../issue28339/foo/../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339" + ); + test!( + "./bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339/foo" + ); + test!( + "../foo/bar.txt", + "/tmp/issue28339/foo/bar.txt", + "/tmp/issue28339/foo" + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + #[should_panic(expected = "Tooltip mismatch")] + async fn issue_34027(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![( + path!("/tmp/issue34027"), + json!({ + "test.txt": "", + "foo": { + "test.txt": "", + } + }), + ),], + vec![path!("/tmp/issue34027")], + { + test!("test.txt", "/tmp/issue34027/test.txt", "/tmp/issue34027"); + test!( + "test.txt", + "/tmp/issue34027/foo/test.txt", + "/tmp/issue34027/foo" + ); + } + ) + } + + // https://github.com/zed-industries/zed/issues/34027 + #[gpui::test] + #[should_panic(expected = "Tooltip mismatch")] + async fn issue_34027_non_worktree_file(cx: &mut TestAppContext) { + test_path_likes!( + cx, + vec![ + ( + path!("/"), + json!({ + "file.txt": "", + }), + ), + ( + path!("/test"), + json!({ + "file.txt": "", + }), + ), + ], + vec![path!("/test")], + { + test!("file.txt", "/file.txt", "/"); + test!("file.txt", "/test/file.txt", "/test"); + } + ) + } + } +} diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5b4d327140..e2f9ba818d 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2,21 +2,21 @@ mod color_contrast; mod persistence; pub mod terminal_element; pub mod terminal_panel; +mod terminal_path_like_target; pub mod terminal_scrollbar; mod terminal_slash_command; pub mod terminal_tab_tooltip; use assistant_slash_command::SlashCommandRegistry; -use editor::{Editor, EditorSettings, actions::SelectAll, scroll::ScrollbarAutoHide}; +use editor::{EditorSettings, actions::SelectAll, scroll::ScrollbarAutoHide}; use gpui::{ Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render, ScrollWheelEvent, Stateful, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div, }; -use itertools::Itertools; use persistence::TERMINAL_DB; -use project::{Entry, Metadata, Project, search::SearchQuery, terminals::TerminalKind}; +use project::{Project, search::SearchQuery, terminals::TerminalKind}; use schemars::JsonSchema; use task::TaskId; use terminal::{ @@ -31,16 +31,17 @@ use terminal::{ }; use terminal_element::TerminalElement; use terminal_panel::TerminalPanel; +use terminal_path_like_target::{hover_path_like_target, open_path_like_target}; use terminal_scrollbar::TerminalScrollHandle; use terminal_slash_command::TerminalSlashCommand; use terminal_tab_tooltip::TerminalTooltip; use ui::{ ContextMenu, Icon, IconName, Label, Scrollbar, ScrollbarState, Tooltip, h_flex, prelude::*, }; -use util::{ResultExt, debug_panic, paths::PathWithPosition}; +use util::ResultExt; use workspace::{ - CloseActiveItem, NewCenterTerminal, NewTerminal, OpenOptions, OpenVisible, ToolbarItemLocation, - Workspace, WorkspaceId, delete_unloaded_items, + CloseActiveItem, NewCenterTerminal, NewTerminal, ToolbarItemLocation, Workspace, WorkspaceId, + delete_unloaded_items, item::{ BreadcrumbText, Item, ItemEvent, SerializableItem, TabContentParams, TabTooltipContent, }, @@ -48,7 +49,6 @@ use workspace::{ searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, }; -use anyhow::Context as _; use serde::Deserialize; use settings::{Settings, SettingsStore}; use smol::Timer; @@ -64,7 +64,6 @@ use std::{ }; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); -const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"]; const TERMINAL_SCROLLBAR_WIDTH: Pixels = px(12.); /// Event to transmit the scroll from the element to the view @@ -181,6 +180,7 @@ impl ContentMode { } #[derive(Debug)] +#[cfg_attr(test, derive(Clone, Eq, PartialEq))] struct HoverTarget { tooltip: String, hovered_word: HoveredWord, @@ -1066,37 +1066,13 @@ fn subscribe_for_terminal_events( .as_ref() .map(|hover| &hover.hovered_word) { - let valid_files_to_open_task = possible_open_target( + terminal_view.hover = None; + terminal_view.hover_tooltip_update = hover_path_like_target( &workspace, - &path_like_target.terminal_dir, - &path_like_target.maybe_path, + hovered_word.clone(), + path_like_target, cx, ); - let hovered_word = hovered_word.clone(); - - terminal_view.hover = None; - terminal_view.hover_tooltip_update = - cx.spawn(async move |terminal_view, cx| { - let file_to_open = valid_files_to_open_task.await; - terminal_view - .update(cx, |terminal_view, _| match file_to_open { - Some( - OpenTarget::File(path, _) - | OpenTarget::Worktree(path, _), - ) => { - terminal_view.hover = Some(HoverTarget { - tooltip: path.to_string(|path| { - path.to_string_lossy().to_string() - }), - hovered_word, - }); - } - None => { - terminal_view.hover = None; - } - }) - .ok(); - }); cx.notify(); } } @@ -1110,86 +1086,13 @@ fn subscribe_for_terminal_events( Event::Open(maybe_navigation_target) => match maybe_navigation_target { MaybeNavigationTarget::Url(url) => cx.open_url(url), - - MaybeNavigationTarget::PathLike(path_like_target) => { - if terminal_view.hover.is_none() { - return; - } - let task_workspace = workspace.clone(); - let path_like_target = path_like_target.clone(); - cx.spawn_in(window, async move |terminal_view, cx| { - let open_target = terminal_view - .update(cx, |_, cx| { - possible_open_target( - &task_workspace, - &path_like_target.terminal_dir, - &path_like_target.maybe_path, - cx, - ) - })? - .await; - if let Some(open_target) = open_target { - let path_to_open = open_target.path(); - let opened_items = task_workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path_to_open.path.clone()], - OpenOptions { - visible: Some(OpenVisible::OnlyDirectories), - ..Default::default() - }, - 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(), - ); - } - - if let Some(opened_item) = opened_items.first() { - if open_target.is_file() { - if let Some(Ok(opened_item)) = opened_item - && let Some(row) = path_to_open.row - { - let col = path_to_open.column.unwrap_or(0); - if let Some(active_editor) = - opened_item.downcast::() - { - active_editor - .downgrade() - .update_in(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(cx, |workspace, cx| { - workspace.project().update(cx, |_, cx| { - cx.emit(project::Event::ActivateProjectPanel); - }) - })?; - } - } - } - - anyhow::Ok(()) - }) - .detach_and_log_err(cx) - } + MaybeNavigationTarget::PathLike(path_like_target) => open_path_like_target( + &workspace, + terminal_view, + path_like_target, + window, + cx, + ), }, Event::BreadcrumbsChanged => cx.emit(ItemEvent::UpdateBreadcrumbs), Event::CloseTerminal => cx.emit(ItemEvent::CloseItem), @@ -1203,241 +1106,6 @@ fn subscribe_for_terminal_events( vec![terminal_subscription, terminal_events_subscription] } -#[derive(Debug, Clone)] -enum OpenTarget { - Worktree(PathWithPosition, Entry), - File(PathWithPosition, 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 path(&self) -> &PathWithPosition { - match self { - OpenTarget::Worktree(path, _) => path, - OpenTarget::File(path, _) => path, - } - } -} - -fn possible_open_target( - workspace: &WeakEntity, - cwd: &Option, - maybe_path: &str, - cx: &App, -) -> Task> { - 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); - let worktree_candidates = 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, - } - }) - .collect::>(); - // Since we do not check paths via FS and joining, we need to strip off potential `./`, `a/`, `b/` prefixes out of it. - for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) { - 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, - }); - } - } - - let insert_both_paths = original_path != path_with_position; - potential_paths.insert(0, original_path); - if insert_both_paths { - potential_paths.insert(1, path_with_position); - } - - // If we won't find paths "easily", we can traverse the entire worktree to look what ends with the potential path suffix. - // That will be slow, though, so do the fast checks first. - let mut worktree_paths_to_check = Vec::new(); - for worktree in &worktree_candidates { - let worktree_root = worktree.read(cx).abs_path(); - let mut paths_to_check = Vec::with_capacity(potential_paths.len()); - - for path_with_position in &potential_paths { - let path_to_check = if worktree_root.ends_with(&path_with_position.path) { - let root_path_with_position = PathWithPosition { - path: worktree_root.to_path_buf(), - row: path_with_position.row, - column: path_with_position.column, - }; - match worktree.read(cx).root_entry() { - Some(root_entry) => { - return Task::ready(Some(OpenTarget::Worktree( - root_path_with_position, - root_entry.clone(), - ))); - } - None => root_path_with_position, - } - } else { - 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, - } - }; - - if path_to_check.path.is_relative() - && let Some(entry) = worktree.read(cx).entry_for_path(&path_to_check.path) - { - return Task::ready(Some(OpenTarget::Worktree( - PathWithPosition { - path: worktree_root.join(&entry.path), - row: path_to_check.row, - column: path_to_check.column, - }, - entry.clone(), - ))); - } - - paths_to_check.push(path_to_check); - } - - if !paths_to_check.is_empty() { - worktree_paths_to_check.push((worktree.clone(), paths_to_check)); - } - } - - // Before entire worktree traversal(s), make an attempt to do FS checks if available. - let fs_paths_to_check = if workspace.read(cx).project().read(cx).is_local() { - 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)) - }) - { - paths_to_check.push(PathWithPosition { - path: home_path, - row: path_to_check.row, - column: path_to_check.column, - }); - } - } 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, - }); - } - for worktree in &worktree_candidates { - paths_to_check.push(PathWithPosition { - path: worktree.read(cx).abs_path().join(maybe_path), - row: path_to_check.row, - column: path_to_check.column, - }); - } - } - } - paths_to_check - }) - .collect() - } else { - Vec::new() - }; - - let worktree_check_task = cx.spawn(async move |cx| { - for (worktree, worktree_paths_to_check) in worktree_paths_to_check { - let found_entry = worktree - .update(cx, |worktree, _| { - let worktree_root = worktree.abs_path(); - let traversal = worktree.traverse_from_path(true, true, false, "".as_ref()); - for entry in traversal { - if let Some(path_in_worktree) = worktree_paths_to_check - .iter() - .find(|path_to_check| entry.path.ends_with(&path_to_check.path)) - { - return Some(OpenTarget::Worktree( - PathWithPosition { - path: worktree_root.join(&entry.path), - row: path_in_worktree.row, - column: path_in_worktree.column, - }, - entry.clone(), - )); - } - } - None - }) - .ok()?; - if let Some(found_entry) = found_entry { - return Some(found_entry); - } - } - None - }); - - let fs = workspace.read(cx).project().read(cx).fs().clone(); - cx.background_spawn(async move { - for mut path_to_check in fs_paths_to_check { - if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok() - && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten() - { - path_to_check.path = fs_path_to_check; - return Some(OpenTarget::File(path_to_check, metadata)); - } - } - - worktree_check_task.await - }) -} - fn regex_search_for_query(query: &project::search::SearchQuery) -> Option { let str = query.as_str(); if query.is_regex() {