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 = [
"anyhow",
"client",
"collections",
"db",
"dirs 4.0.0",
"editor",

View file

@ -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)));
}

View file

@ -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

View file

@ -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> {

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.
/// 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>,