Add support for relative terminal links (#7303)

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>
This commit is contained in:
Robin Pfäffle 2024-02-03 16:04:27 +01:00 committed by GitHub
parent 54aecd21ec
commit 06674a21f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 168 additions and 79 deletions

1
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

@ -121,7 +121,7 @@ pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
/// A representation of a path-like string with optional row and column numbers. /// A representation of a path-like string with optional row and column numbers.
/// Matching values example: `te`, `test.rs:22`, `te:22:5`, etc. /// 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 struct PathLikeWithPosition<P> {
pub path_like: P, pub path_like: P,
pub row: Option<u32>, pub row: Option<u32>,