Improve cmd-click in terminal to find more paths (#26174)
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
This commit is contained in:
parent
43339c6869
commit
d3c68650c0
3 changed files with 210 additions and 173 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -13610,6 +13610,7 @@ dependencies = [
|
|||
"gpui",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"log",
|
||||
"project",
|
||||
"rand 0.8.5",
|
||||
"schemars",
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let mut paths_with_metadata = Vec::with_capacity(canonical_paths.len());
|
||||
|
||||
let mut fetch_metadata_tasks = canonical_paths
|
||||
.into_iter()
|
||||
.map(|potential_path| async {
|
||||
let metadata = fs.metadata(&potential_path).await.ok().flatten();
|
||||
(
|
||||
PathWithPosition {
|
||||
path: 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
|
||||
})
|
||||
#[derive(Debug, Clone)]
|
||||
enum OpenTarget {
|
||||
Worktree(Entry),
|
||||
File(Metadata),
|
||||
}
|
||||
|
||||
fn possible_open_targets(
|
||||
fs: Arc<dyn Fs>,
|
||||
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: &String,
|
||||
|
||||
maybe_path: &str,
|
||||
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))
|
||||
{
|
||||
potential_cwd_and_workspace_paths.insert(potential_worktree_path);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
) -> 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,
|
||||
});
|
||||
}
|
||||
potential_cwd_and_workspace_paths
|
||||
};
|
||||
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);
|
||||
|
||||
possible_open_paths_metadata(fs, row, column, potential_paths, cx)
|
||||
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 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: worktree_root.join(&entry.path),
|
||||
row: path_in_worktree.row,
|
||||
column: path_in_worktree.column,
|
||||
},
|
||||
OpenTarget::Worktree(entry.clone()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !workspace.read(cx).project().read(cx).is_local() {
|
||||
return Task::ready(None);
|
||||
}
|
||||
let fs = workspace.read(cx).project().read(cx).fs().clone();
|
||||
|
||||
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))
|
||||
})
|
||||
{
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
paths_to_check
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue