Make file paths in backticks clickable in agent panel (#57303)

When the agent mentions a file path inside `backticks` (e.g. ``
`src/main.rs` `` or `` `src/main.rs:42` ``), the rendered code span now
becomes a clickable link in the agent panel. Clicking opens the
referenced file in the workspace, jumping to the right line and column
when present.

## How it works

- **Shared path resolution.** Extracted `OpenTarget` and the
workspace/worktree resolution logic out of
`terminal_view::terminal_path_like_target` into a new
`workspace::path_link` module so both the terminal and the agent panel
can use the same code. Includes a `sanitize_path_text` helper ported
from the terminal's URL/punctuation handling. Pure refactor — terminal
behavior is unchanged.
- **`markdown` crate hook.** Added
`MarkdownElement::on_code_span_link(callback)`. When the callback
returns `Some(url)` for a given code span's contents, the existing
`push_link` machinery wires up cmd-hover, hit testing, and the existing
`on_url_click` callback. When it returns `None`, the code span renders
as before. The hook is opt-in, so `markdown` stays workspace-agnostic.
- **Agent panel wiring.** `render_agent_markdown` constructs an
`AgentCodeSpanResolver` that snapshots the project's visible worktree
entries plus their file extensions. `try_resolve` does a cheap
synchronous heuristic check (path must contain `/`/`\` or end in an
extension present in the workspace, can't be a URL, can't be all digits,
etc.) and then looks the candidate up in the per-worktree
`HashSet<Arc<RelPath>>`. On a hit it returns a `MentionUri::File` or
`MentionUri::Selection` URI, which the existing `thread_view::open_link`
already knows how to open at the right line.

## Edge cases handled

- Code spans inside fenced code blocks stay plain (gated on
`builder.code_block_stack.is_empty()`, matching how regular markdown
links behave).
- Trailing prose punctuation (`` `src/main.rs.` ``) is stripped before
lookup.
- Identifiers like `` `String` ``, `` `await` ``, `` `npm run dev` ``
stay plain — they don't pass the path-like heuristic.
- Cross-platform path separators handled via the per-worktree
`PathStyle`.

## Tests

- `crates/markdown` — unit test asserting code spans become links when
the callback returns `Some`, and stay plain when it doesn't.
- `crates/agent_ui` — unit test for `AgentCodeSpanResolver::try_resolve`
covering hits with and without a `:line` suffix, misses, identifiers,
and trailing punctuation.
- Existing `terminal_view` tests cover the moved resolution code
(unchanged behavior).

## Notes

- There's currently a temporary `log::info!` in
`AgentCodeSpanResolver::try_resolve` that reports per-call worktree-walk
timing and a cumulative total. Kept in for now to verify the feature
isn't being called excessively during streaming renders. Can be removed
before merge.
- Resolution is sync-only against worktree entries; absolute paths
outside the workspace are not resolved (would require an async re-render
path).

Closes AI-277

Release Notes:

- Made file paths in `backticks` clickable in the agent panel; clicking
opens the referenced file at the given line when present.
This commit is contained in:
MartinYe1234 2026-05-21 15:04:32 -07:00 committed by GitHub
parent 77cbba9b1a
commit f78f6da255
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1096 additions and 449 deletions

2
Cargo.lock generated
View file

@ -407,6 +407,7 @@ dependencies = [
"language_models",
"languages",
"log",
"lru",
"lsp",
"markdown",
"menu",
@ -22018,6 +22019,7 @@ dependencies = [
"collections",
"component",
"db",
"dirs",
"fs",
"futures 0.3.32",
"futures-lite 1.13.0",

View file

@ -622,6 +622,7 @@ linkify = "0.10.0"
libwebrtc = "0.3.26"
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
lru = "0.16"
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" }
mach2 = "0.5"
markup5ever_rcdom = "0.3.0"

View file

@ -51,6 +51,8 @@ pub enum MentionUri {
#[serde(default, skip_serializing_if = "Option::is_none")]
abs_path: Option<PathBuf>,
line_range: RangeInclusive<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
column: Option<u32>,
},
Fetch {
url: Url,
@ -105,6 +107,17 @@ impl MentionUri {
Ok(start_line..=end_line)
}
let parse_column =
|input: Option<String>| -> Option<u32> { input?.parse::<u32>().ok()?.checked_sub(1) };
let validate_query_params = |url: &Url, allowed: &[&str]| -> Result<()> {
for (key, _) in url.query_pairs() {
if !allowed.contains(&key.as_ref()) {
bail!("invalid query parameter")
}
}
Ok(())
};
let parse_absolute_path = |input: &str| -> Result<Self> {
let (path_input, fragment) = input
.split_once('#')
@ -114,6 +127,7 @@ impl MentionUri {
return Ok(MentionUri::Selection {
abs_path: Some(path_input.into()),
line_range: fragment,
column: None,
});
}
@ -123,10 +137,12 @@ impl MentionUri {
let line = row
.checked_sub(1)
.context("Line numbers should be 1-based")?;
// TODO: Preserve column info too.
Ok(MentionUri::Selection {
abs_path: Some(abs_path),
line_range: line..=line,
column: path_with_position
.column
.map(|column| column.saturating_sub(1)),
})
} else {
Ok(MentionUri::File { abs_path })
@ -156,8 +172,10 @@ impl MentionUri {
let path = normalized.as_ref();
if let Some(fragment) = url.fragment() {
validate_query_params(&url, &["symbol", "column"])?;
let line_range = parse_line_range(fragment).log_err().unwrap_or(1..=1);
if let Some(name) = single_query_param(&url, "symbol")? {
let column = parse_column(query_param(&url, "column"));
if let Some(name) = query_param(&url, "symbol") {
Ok(Self::Symbol {
name,
abs_path: path.into(),
@ -167,6 +185,7 @@ impl MentionUri {
Ok(Self::Selection {
abs_path: Some(path.into()),
line_range,
column,
})
}
} else if input.ends_with("/") {
@ -216,9 +235,11 @@ impl MentionUri {
.fragment()
.context("Missing fragment for untitled buffer selection")?;
let line_range = parse_line_range(fragment)?;
validate_query_params(&url, &["column"])?;
Ok(Self::Selection {
abs_path: None,
line_range,
column: parse_column(query_param(&url, "column")),
})
} else if let Some(name) = path.strip_prefix("/agent/symbol/") {
let fragment = url
@ -245,13 +266,15 @@ impl MentionUri {
abs_path: path.into(),
})
} else if path.starts_with("/agent/selection") {
validate_query_params(&url, &["path", "column"])?;
let fragment = url.fragment().context("Missing fragment for selection")?;
let line_range = parse_line_range(fragment)?;
let path =
single_query_param(&url, "path")?.context("Missing path for selection")?;
let column = parse_column(query_param(&url, "column"));
let path = query_param(&url, "path").context("Missing path for selection")?;
Ok(Self::Selection {
abs_path: Some(path.into()),
line_range,
column,
})
} else if path.starts_with("/agent/terminal-selection") {
let line_count = single_query_param(&url, "lines")?
@ -460,6 +483,7 @@ impl MentionUri {
abs_path,
name,
line_range,
..
} => {
let mut url = Url::parse("file:///").unwrap();
url.set_path(&abs_path.to_string_lossy());
@ -474,6 +498,7 @@ impl MentionUri {
MentionUri::Selection {
abs_path,
line_range,
column,
} => {
let mut url = if let Some(path) = abs_path {
let mut url = Url::parse("file:///").unwrap();
@ -484,6 +509,10 @@ impl MentionUri {
url.set_path("/agent/untitled-buffer");
url
};
if let Some(column) = column {
url.query_pairs_mut()
.append_pair("column", &(column + 1).to_string());
}
url.set_fragment(Some(&format!(
"L{}:{}",
line_range.start() + 1,
@ -564,6 +593,11 @@ fn default_include_errors() -> bool {
true
}
fn query_param(url: &Url, name: &'static str) -> Option<String> {
url.query_pairs()
.find_map(|(key, value)| (key == name).then(|| value.to_string()))
}
fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
let pairs = url.query_pairs().collect::<Vec<_>>();
match pairs.as_slice() {
@ -698,6 +732,7 @@ mod tests {
abs_path: path,
name,
line_range,
..
} => {
assert_eq!(path, Path::new(path!("/path/to/file.rs")));
assert_eq!(name, "MySymbol");
@ -717,6 +752,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &4);
@ -748,6 +784,7 @@ mod tests {
MentionUri::Selection {
abs_path: None,
line_range,
..
} => {
assert_eq!(line_range.start(), &0);
assert_eq!(line_range.end(), &9);
@ -895,6 +932,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
@ -904,6 +942,29 @@ mod tests {
}
}
#[test]
fn test_parse_absolute_file_path_with_row_and_column() {
let file_path = "/path/to/file.rs:42:5";
let parsed = MentionUri::parse(file_path, PathStyle::Posix).unwrap();
match &parsed {
MentionUri::Selection {
abs_path: path,
line_range,
column,
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
assert_eq!(line_range.end(), &41);
assert_eq!(column, &Some(4));
let parsed_again = MentionUri::parse(parsed.to_uri().as_ref(), PathStyle::Posix)
.expect("selection URI with column should parse");
assert_eq!(parsed_again, parsed.clone());
}
_ => panic!("Expected Selection variant"),
}
}
#[test]
fn test_parse_absolute_file_path_with_fragment_line() {
let file_path = "/path/to/file.rs#L42";
@ -912,6 +973,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
@ -941,6 +1003,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(
path.as_ref().unwrap(),
@ -961,6 +1024,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(
path.as_ref().unwrap(),
@ -993,6 +1057,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
assert_eq!(line_range.start(), &41);
@ -1010,6 +1075,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(
path.as_ref().unwrap(),
@ -1031,6 +1097,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &1871);
@ -1048,6 +1115,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9);
@ -1063,6 +1131,7 @@ mod tests {
MentionUri::Selection {
abs_path: path,
line_range,
..
} => {
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
assert_eq!(line_range.start(), &9);

View file

@ -69,6 +69,7 @@ language.workspace = true
language_model.workspace = true
language_models.workspace = true
log.workspace = true
lru.workspace = true
lsp.workspace = true
markdown.workspace = true
menu.workspace = true

View file

@ -43,10 +43,12 @@ use ::ui::IconName;
use agent_client_protocol::schema as acp;
use agent_settings::{AgentProfileId, AgentSettings};
use command_palette_hooks::CommandPaletteFilter;
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
use feature_flags::FeatureFlagAppExt as _;
use fs::Fs;
use gpui::{
Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, Window, actions,
Action, App, Context, Entity, ImageSource, Resource, SharedString, SharedUri, TaskExt, Window,
actions,
};
use language::{
LanguageRegistry,
@ -57,6 +59,7 @@ use language_model::{
};
use project::{AgentId, DisableAiSettings};
use prompt_store::{PromptBuilder, rules_to_skills_migration};
use rope::Point;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide};
@ -112,6 +115,42 @@ pub(crate) fn resolve_agent_image(
None
}
pub(crate) fn open_abs_path_at_point(
workspace: &mut Workspace,
abs_path: PathBuf,
point: Point,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> bool {
let project = workspace.project();
let Some(path) = project.update(cx, |project, cx| project.find_project_path(abs_path, cx))
else {
return false;
};
let item = workspace.open_path(path, None, true, window, cx);
window
.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
let range = point..point;
editor
.update_in(cx, |editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::center()),
window,
cx,
|selections| selections.select_ranges([range]),
);
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
true
}
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";

View file

@ -40,14 +40,16 @@ use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
use markdown::{
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
};
use parking_lot::RwLock;
use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
use parking_lot::{Mutex, RwLock};
use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
use prompt_store::{PromptId, PromptStore};
use crate::message_editor::SessionCapabilities;
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
use lru::LruCache;
use rope::Point;
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay};
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
@ -61,11 +63,17 @@ use ui::{
KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
right_click_menu,
};
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
use util::{debug_panic, defer};
use util::{
ResultExt, debug_panic, defer,
paths::{PathStyle, PathWithPosition},
rel_path::RelPath,
size::format_file_size,
time::duration_alt_display,
};
use workspace::PathList;
use workspace::{
CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
path_link::sanitize_path_text,
};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
@ -509,6 +517,9 @@ pub struct ConversationView {
/// causes mermaid diagrams to re-render).
last_theme_id: Option<String>,
draft_prompt_persist_task: Option<Task<()>>,
/// Cache + worktree snapshot for resolving paths in markdown code spans.
/// Shared with the child [`ThreadView`] when one is constructed.
pub(crate) code_span_resolver: AgentCodeSpanResolver,
_subscriptions: Vec<Subscription>,
}
@ -707,7 +718,8 @@ impl ConversationView {
cx: &mut Context<Self>,
) -> Self {
let agent_server_store = project.read(cx).agent_server_store().clone();
let subscriptions = vec![
let code_span_resolver = AgentCodeSpanResolver::new(&project.downgrade(), cx);
let mut subscriptions = vec![
cx.observe_global_in::<SettingsStore>(window, Self::agent_ui_font_size_changed),
cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
@ -718,6 +730,20 @@ impl ConversationView {
Self::handle_agent_servers_updated,
),
];
subscriptions.push(cx.subscribe(&project, {
let resolver = code_span_resolver.clone();
move |_this: &mut Self, _project, event: &project::Event, cx| {
if matches!(
event,
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeUpdatedEntries(_, _)
) {
resolver.clear_cache();
cx.notify();
}
}
}));
cx.on_release(|this, cx| {
if let Some(connected) = this.as_connected() {
@ -764,6 +790,7 @@ impl ConversationView {
auth_task: None,
last_theme_id: Some(cx.theme().id.clone()),
draft_prompt_persist_task: None,
code_span_resolver,
_subscriptions: subscriptions,
focus_handle: cx.focus_handle(),
}
@ -1218,6 +1245,7 @@ impl ConversationView {
session_capabilities,
resumed_without_history,
self.project.downgrade(),
self.code_span_resolver.clone(),
self.thread_store.clone(),
self.prompt_store.clone(),
initial_content,
@ -2511,7 +2539,7 @@ impl ConversationView {
markdown,
style,
&self.workspace,
&self.project.downgrade(),
&self.code_span_resolver,
cx,
)
}
@ -3118,20 +3146,12 @@ fn render_agent_markdown(
markdown: Entity<Markdown>,
style: MarkdownStyle,
workspace: &WeakEntity<Workspace>,
project: &WeakEntity<Project>,
code_span_resolver: &AgentCodeSpanResolver,
cx: &App,
) -> MarkdownElement {
let workspace = workspace.clone();
let worktree_roots: Vec<PathBuf> = project
.upgrade()
.map(|project| {
project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect()
})
.unwrap_or_default();
let worktree_roots = code_span_resolver.worktree_roots(cx);
let resolver = code_span_resolver.clone();
MarkdownElement::new(markdown, style)
.code_block_renderer(markdown::CodeBlockRenderer::Default {
copy_button_visibility: markdown::CopyButtonVisibility::VisibleOnHover,
@ -3142,6 +3162,175 @@ fn render_agent_markdown(
.on_url_click(move |text, window, cx| {
thread_view::open_link(text, &workspace, window, cx);
})
.on_code_span_link(move |text, cx| resolver.try_resolve(text, cx))
}
/// Shared, cloneable handle for resolving inline markdown code spans like
/// `` `src/main.rs:42` `` to clickable workspace file links.
#[derive(Clone)]
pub(crate) struct AgentCodeSpanResolver {
inner: Arc<AgentCodeSpanResolverInner>,
}
/// Maximum number of memoized code-span resolutions kept in the cache.
const CODE_SPAN_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(2048) {
Some(n) => n,
None => unreachable!(),
};
struct AgentCodeSpanResolverInner {
project: WeakEntity<Project>,
cache: Mutex<LruCache<Arc<str>, Option<SharedString>>>,
}
impl AgentCodeSpanResolver {
pub(crate) fn new(project: &WeakEntity<Project>, _cx: &App) -> Self {
Self {
inner: Arc::new(AgentCodeSpanResolverInner {
project: project.clone(),
cache: Mutex::new(LruCache::new(CODE_SPAN_CACHE_CAPACITY)),
}),
}
}
pub(crate) fn clear_cache(&self) {
self.inner.cache.lock().clear();
}
/// Absolute paths of every current worktree.
/// Used by the markdown image resolver, which needs the same set of roots.
fn worktree_roots(&self, cx: &App) -> Vec<PathBuf> {
self.inner
.project
.upgrade()
.map(|project| {
project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect()
})
.unwrap_or_default()
}
fn try_resolve(&self, text: &str, cx: &App) -> Option<SharedString> {
let trimmed = sanitize_path_text(text.trim());
if !Self::is_path_like(trimmed) {
return None;
}
if let Some(cached) = self.inner.cache.lock().get(trimmed).cloned() {
return cached;
}
let resolved = self.resolve_uncached(trimmed, cx);
self.inner
.cache
.lock()
.push(Arc::from(trimmed), resolved.clone());
resolved
}
fn resolve_uncached(&self, trimmed: &str, cx: &App) -> Option<SharedString> {
let path_with_position = PathWithPosition::parse_str(trimmed);
let candidate_path = &path_with_position.path;
if candidate_path.as_os_str().is_empty() {
return None;
}
let project = self.inner.project.upgrade()?;
let project = project.read(cx);
for worktree in project.visible_worktrees(cx) {
let worktree = worktree.read(cx);
for relative_path in Self::candidate_relative_paths(
candidate_path,
&worktree.abs_path(),
worktree.path_style(),
) {
let project_path = ProjectPath {
worktree_id: worktree.id(),
path: relative_path.clone(),
};
let Some(entry) = project.entry_for_path(&project_path, cx) else {
continue;
};
if !entry.is_file() {
continue;
}
let abs_path = worktree.absolutize(&relative_path);
let mention = match path_with_position.row.and_then(|row| row.checked_sub(1)) {
Some(line) => MentionUri::Selection {
abs_path: Some(abs_path),
line_range: line..=line,
column: path_with_position
.column
.map(|column| column.saturating_sub(1)),
},
None => MentionUri::File { abs_path },
};
return Some(mention.to_uri().to_string().into());
}
}
None
}
fn candidate_relative_paths(
path: &Path,
worktree_abs_path: &Path,
path_style: PathStyle,
) -> Vec<Arc<RelPath>> {
let path_text = path.to_string_lossy();
let relative_path: Option<Arc<RelPath>> =
if util::paths::is_absolute(path_text.as_ref(), path_style) {
path_style
.strip_prefix(path, worktree_abs_path)
.map(std::borrow::Cow::into_owned)
.map(Into::into)
} else {
RelPath::new(path, path_style)
.ok()
.map(std::borrow::Cow::into_owned)
.map(Into::into)
};
let Some(relative_path) = relative_path else {
return Vec::new();
};
let mut paths = vec![relative_path.clone()];
if let Some(root_name) = worktree_abs_path.file_name().and_then(|name| name.to_str())
&& let Ok(root_name) = RelPath::new(Path::new(root_name), path_style)
&& let Ok(stripped) = relative_path.strip_prefix(root_name.as_ref())
&& !stripped.is_empty()
{
paths.push(Arc::from(stripped));
}
paths
}
fn is_path_like(text: &str) -> bool {
if text.len() < 3
|| text.contains("://")
|| text.contains('|')
|| text.chars().any(char::is_control)
|| text.chars().all(|character| character.is_ascii_digit())
{
return false;
}
let path = PathWithPosition::parse_str(text).path;
let path_text = path.to_string_lossy();
if path_text.contains('/') || path_text.contains('\\') {
return true;
}
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| !extension.is_empty())
}
}
fn plan_label_markdown_style(
@ -3256,6 +3445,82 @@ pub(crate) mod tests {
});
}
#[gpui::test]
async fn test_agent_code_span_resolver_resolves_worktree_paths(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
util::path!("/project"),
json!({
"src": {
"main.rs": ""
},
"README.md": ""
}),
)
.await;
let project = Project::test(fs, [Path::new(util::path!("/project"))], cx).await;
let resolver = cx.update(|cx| AgentCodeSpanResolver::new(&project.downgrade(), cx));
let uri = cx
.update(|cx| resolver.try_resolve("src/main.rs:10", cx))
.expect("expected worktree-relative file path to resolve");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::Selection {
abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
line_range: 9..=9,
column: None,
}
);
let uri = cx
.update(|cx| resolver.try_resolve("src/main.rs:10:5", cx))
.expect("expected worktree-relative file path with row and column to resolve");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::Selection {
abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
line_range: 9..=9,
column: Some(4),
}
);
let uri = cx
.update(|cx| resolver.try_resolve("src/main.rs:0", cx))
.expect("`:0` should fall back to a file mention instead of returning None");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::File {
abs_path: PathBuf::from(util::path!("/project/src/main.rs")),
}
);
assert!(cx.update(|cx| resolver.try_resolve("String", cx)).is_none());
assert!(
cx.update(|cx| resolver.try_resolve("does/not/exist.rs", cx))
.is_none()
);
assert!(
cx.update(|cx| resolver.try_resolve("src/main.rs.", cx))
.is_some()
);
let uri = cx
.update(|cx| resolver.try_resolve("project/src/main.rs:10", cx))
.expect("expected root-prefixed worktree path to resolve");
assert_eq!(
MentionUri::parse(&uri, PathStyle::local()).unwrap(),
MentionUri::Selection {
abs_path: Some(PathBuf::from(util::path!("/project/src/main.rs"))),
line_range: 9..=9,
column: None,
}
);
}
#[gpui::test]
async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -1,6 +1,7 @@
use crate::{
DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
agent_configuration::configure_context_server_modal::default_markdown_style,
open_abs_path_at_point,
thread_metadata_store::{ThreadId, ThreadMetadataStore},
};
use agent_client_protocol::schema as acp;
@ -330,6 +331,10 @@ pub struct ThreadView {
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
pub project: WeakEntity<Project>,
/// Cache + worktree snapshot for resolving paths in markdown code spans.
/// Cloned from the parent `ConversationView` so the cache is shared and the
/// snapshot stays in sync via the parent's project-event subscription.
pub(crate) code_span_resolver: AgentCodeSpanResolver,
pub show_external_source_prompt_warning: bool,
pub show_codex_windows_warning: bool,
pub multi_root_callout_dismissed: bool,
@ -382,6 +387,7 @@ impl ThreadView {
session_capabilities: SharedSessionCapabilities,
resumed_without_history: bool,
project: WeakEntity<Project>,
code_span_resolver: AgentCodeSpanResolver,
thread_store: Option<Entity<ThreadStore>>,
prompt_store: Option<Entity<PromptStore>>,
initial_content: Option<AgentInitialContent>,
@ -449,6 +455,23 @@ impl ThreadView {
&& project.upgrade().is_some_and(|p| p.read(cx).is_local())
&& agent_id.as_ref() == "Codex";
if let Some(project) = project.upgrade() {
subscriptions.push(cx.subscribe(&project, {
let resolver = code_span_resolver.clone();
move |_this: &mut Self, _project, event: &project::Event, cx| {
if matches!(
event,
project::Event::WorktreeAdded(_)
| project::Event::WorktreeRemoved(_)
| project::Event::WorktreeUpdatedEntries(_, _)
) {
resolver.clear_cache();
cx.notify();
}
}
}));
}
let title_editor = {
let metadata = ThreadMetadataStore::try_global(cx)
.and_then(|store| store.read(cx).entry(root_thread_id).cloned());
@ -601,6 +624,7 @@ impl ThreadView {
add_context_menu_handle: PopoverMenuHandle::default(),
thinking_effort_menu_handle: PopoverMenuHandle::default(),
project,
code_span_resolver,
show_external_source_prompt_warning,
show_codex_windows_warning,
multi_root_callout_dismissed: false,
@ -8701,7 +8725,13 @@ impl ThreadView {
style: MarkdownStyle,
cx: &App,
) -> MarkdownElement {
render_agent_markdown(markdown, style, &self.workspace, &self.project, cx)
render_agent_markdown(
markdown,
style,
&self.workspace,
&self.code_span_resolver,
cx,
)
}
fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
@ -9372,39 +9402,27 @@ pub(crate) fn open_link(
abs_path: path,
line_range,
..
} => {
open_abs_path_at_point(
workspace,
path,
Point::new(*line_range.start(), 0),
window,
cx,
);
}
| MentionUri::Selection {
MentionUri::Selection {
abs_path: Some(path),
line_range,
column,
} => {
let project = workspace.project();
let Some(path) =
project.update(cx, |project, cx| project.find_project_path(path, cx))
else {
return;
};
let item = workspace.open_path(path, None, true, window, cx);
window
.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
let range =
Point::new(*line_range.start(), 0)..Point::new(*line_range.start(), 0);
editor
.update_in(cx, |editor, window, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::center()),
window,
cx,
|s| s.select_ranges(vec![range]),
);
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
open_abs_path_at_point(
workspace,
path,
Point::new(*line_range.start(), column.unwrap_or(0)),
window,
cx,
);
}
MentionUri::Selection { abs_path: None, .. } => {}
MentionUri::Thread { id, name } => {

View file

@ -167,6 +167,7 @@ impl MentionSet {
MentionUri::Selection {
abs_path: Some(abs_path),
line_range,
..
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
"Untitled buffer selection mentions are not supported for paste"
@ -570,6 +571,7 @@ impl MentionSet {
let uri = MentionUri::Selection {
abs_path: abs_path.clone(),
line_range: line_range.clone(),
column: None,
};
let crease = crease_for_mention(
selection_name(abs_path.as_deref(), &line_range).into(),
@ -805,6 +807,7 @@ mod tests {
MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 1..=2,
column: None,
},
false,
http_client,

View file

@ -1119,6 +1119,7 @@ impl MessageEditor {
let mention_uri = MentionUri::Selection {
abs_path: Some(file_path.clone()),
line_range: line_range.clone(),
column: None,
};
let mention_text = mention_uri.as_link().to_string();
@ -4397,10 +4398,12 @@ mod tests {
let first_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 0..=1,
column: None,
};
let second_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 2..=3,
column: None,
};
source_message_editor.update_in(&mut cx, |message_editor, window, cx| {
@ -4558,10 +4561,12 @@ mod tests {
let first_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 0..=1,
column: None,
};
let second_uri = MentionUri::Selection {
abs_path: Some(path!("/project/file.rs").into()),
line_range: 2..=3,
column: None,
};
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {

View file

@ -1,8 +1,8 @@
use std::{ops::RangeInclusive, path::PathBuf, time::Duration};
use std::{path::PathBuf, time::Duration};
use acp_thread::MentionUri;
use agent_client_protocol::schema as acp;
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
use editor::Editor;
use gpui::{
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
pulsating_between,
@ -15,6 +15,8 @@ use theme_settings::ThemeSettings;
use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
use workspace::{OpenOptions, Workspace};
use crate::open_abs_path_at_point;
#[derive(IntoElement)]
pub struct MentionCrease {
id: ElementId,
@ -165,12 +167,27 @@ fn open_mention_uri(
abs_path,
line_range,
..
} => {
open_file(
workspace,
abs_path,
Some(Point::new(*line_range.start(), 0)),
window,
cx,
);
}
| MentionUri::Selection {
MentionUri::Selection {
abs_path: Some(abs_path),
line_range,
column,
} => {
open_file(workspace, abs_path, Some(line_range), window, cx);
open_file(
workspace,
abs_path,
Some(Point::new(*line_range.start(), column.unwrap_or(0))),
window,
cx,
);
}
MentionUri::Directory { abs_path } => {
reveal_in_project_panel(workspace, abs_path, cx);
@ -260,40 +277,23 @@ fn open_skill_file(
fn open_file(
workspace: &mut Workspace,
abs_path: PathBuf,
line_range: Option<RangeInclusive<u32>>,
point: Option<Point>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project();
if let Some(point) = point {
if open_abs_path_at_point(workspace, abs_path.clone(), point, window, cx) {
return;
}
}
let project = workspace.project();
if let Some(project_path) =
project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
{
let item = workspace.open_path(project_path, None, true, window, cx);
if let Some(line_range) = line_range {
window
.spawn(cx, async move |cx| {
let Some(editor) = item.await?.downcast::<Editor>() else {
return Ok(());
};
editor
.update_in(cx, |editor, window, cx| {
let range = Point::new(*line_range.start(), 0)
..Point::new(*line_range.start(), 0);
editor.change_selections(
SelectionEffects::scroll(Autoscroll::center()),
window,
cx,
|selections| selections.select_ranges(vec![range]),
);
})
.ok();
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
item.detach_and_log_err(cx);
}
workspace
.open_path(project_path, None, true, window, cx)
.detach_and_log_err(cx);
} else if abs_path.exists() {
workspace
.open_abs_path(

View file

@ -53,6 +53,7 @@ use crate::parser::CodeBlockKind;
/// A callback function that can be used to customize the style of links based on the destination URL.
/// If the callback returns `None`, the default link style will be used.
type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
pub type CodeSpanLinkCallback = Arc<dyn Fn(&str, &App) -> Option<SharedString> + 'static>;
type SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
@ -1079,6 +1080,7 @@ pub struct MarkdownElement {
style: MarkdownStyle,
code_block_renderer: CodeBlockRenderer,
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
code_span_link: Option<CodeSpanLinkCallback>,
on_source_click: Option<SourceClickCallback>,
on_checkbox_toggle: Option<CheckboxToggleCallback>,
image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
@ -1097,6 +1099,7 @@ impl MarkdownElement {
border: false,
},
on_url_click: None,
code_span_link: None,
on_source_click: None,
on_checkbox_toggle: None,
image_resolver: None,
@ -1139,6 +1142,14 @@ impl MarkdownElement {
self
}
pub fn on_code_span_link(
mut self,
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
) -> Self {
self.code_span_link = Some(Arc::new(callback));
self
}
pub fn on_source_click(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
@ -1173,6 +1184,41 @@ impl MarkdownElement {
self
}
fn push_markdown_code_span(
&self,
builder: &mut MarkdownElementBuilder,
text: &str,
range: Range<usize>,
cx: &App,
) {
let link_url = if builder.code_block_stack.is_empty() && builder.link_depth == 0 {
self.code_span_link
.as_ref()
.and_then(|callback| callback(text, cx))
} else {
None
};
if let Some(url) = link_url {
builder.push_link(url.clone(), range.clone());
let link_style = self
.style
.link_callback
.as_ref()
.and_then(|callback| callback(url.as_ref(), cx))
.unwrap_or_else(|| self.style.link.clone());
builder.push_text_style(self.style.inline_code.clone());
builder.push_text_style(link_style);
builder.push_text(text, range);
builder.pop_text_style();
builder.pop_text_style();
} else {
builder.push_text_style(self.style.inline_code.clone());
builder.push_text(text, range);
builder.pop_text_style();
}
}
fn push_markdown_image(
&self,
builder: &mut MarkdownElementBuilder,
@ -2013,6 +2059,7 @@ impl Element for MarkdownElement {
}
MarkdownTag::Link { dest_url, .. } => {
if builder.code_block_stack.is_empty() {
builder.link_depth += 1;
builder.push_link(dest_url.clone(), range.clone());
let style = self
.style
@ -2239,6 +2286,7 @@ impl Element for MarkdownElement {
MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
MarkdownTagEnd::Link => {
if builder.code_block_stack.is_empty() {
builder.link_depth = builder.link_depth.saturating_sub(1);
builder.pop_text_style()
}
}
@ -2273,9 +2321,12 @@ impl Element for MarkdownElement {
builder.push_text(text, range.clone());
}
MarkdownEvent::Code => {
builder.push_text_style(self.style.inline_code.clone());
builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
builder.pop_text_style();
self.push_markdown_code_span(
&mut builder,
&parsed_markdown.source[range.clone()],
range.clone(),
cx,
);
}
MarkdownEvent::Html => {
let html = &parsed_markdown.source[range.clone()];
@ -2293,6 +2344,19 @@ impl Element for MarkdownElement {
}
MarkdownEvent::InlineHtml => {
let html = &parsed_markdown.source[range.clone()];
if let Some(code) = html
.strip_prefix("<code>")
.and_then(|html| html.strip_suffix("</code>"))
{
let code_start = range.start + "<code>".len();
self.push_markdown_code_span(
&mut builder,
code,
code_start..code_start + code.len(),
cx,
);
continue;
}
if html.starts_with("<code>") {
builder.push_text_style(self.style.inline_code.clone());
continue;
@ -2653,6 +2717,7 @@ struct MarkdownElementBuilder {
base_text_style: TextStyle,
text_style_stack: Vec<TextStyleRefinement>,
code_block_stack: Vec<Option<Arc<Language>>>,
link_depth: usize,
list_stack: Vec<ListStackEntry>,
table: TableState,
syntax_theme: Arc<SyntaxTheme>,
@ -2691,6 +2756,7 @@ impl MarkdownElementBuilder {
base_text_style,
text_style_stack: Vec::new(),
code_block_stack: Vec::new(),
link_depth: 0,
list_stack: Vec::new(),
table: TableState::default(),
syntax_theme,
@ -3470,6 +3536,40 @@ mod tests {
render_markdown_with_language_registry(markdown, None, cx)
}
fn render_markdown_with_code_span_link(
markdown: &str,
callback: impl Fn(&str, &App) -> Option<SharedString> + 'static,
cx: &mut TestAppContext,
) -> RenderedText {
struct TestWindow;
impl Render for TestWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
ensure_theme_initialized(cx);
let (_, cx) = cx.add_window_view(|_, _| TestWindow);
let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx));
cx.run_until_parked();
let (rendered, _) = cx.draw(
Default::default(),
size(px(600.0), px(600.0)),
|_window, _cx| {
MarkdownElement::new(markdown, MarkdownStyle::default())
.on_code_span_link(callback)
.code_block_renderer(CodeBlockRenderer::Default {
copy_button_visibility: CopyButtonVisibility::Hidden,
wrap_button_visibility: WrapButtonVisibility::Hidden,
border: false,
})
},
);
rendered.text
}
fn render_markdown_with_language_registry(
markdown: &str,
language_registry: Option<Arc<LanguageRegistry>>,
@ -4105,6 +4205,50 @@ mod tests {
assert!(rendered.link_for_source_index(5).is_none());
}
#[gpui::test]
fn test_code_span_link_detected_for_source_index(cx: &mut TestAppContext) {
let source = "see `foo.rs` for details";
let rendered = render_markdown_with_code_span_link(
source,
|text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()),
cx,
);
assert_eq!(rendered.links.len(), 1);
assert_eq!(rendered.links[0].destination_url, "file:///tmp/foo.rs");
let code_index = source.find("foo.rs").unwrap();
let link = rendered.link_for_source_index(code_index);
assert!(link.is_some());
assert_eq!(link.unwrap().destination_url, "file:///tmp/foo.rs");
assert!(
rendered
.link_for_source_index(source.find("see").unwrap())
.is_none()
);
}
#[gpui::test]
fn test_code_span_link_ignores_code_without_callback(cx: &mut TestAppContext) {
let rendered = render_markdown("see `foo.rs` for details", cx);
assert!(rendered.links.is_empty());
}
#[gpui::test]
fn test_code_span_link_ignores_code_inside_markdown_link(cx: &mut TestAppContext) {
let source = "see [`foo.rs`](https://example.com) for details";
let rendered = render_markdown_with_code_span_link(
source,
|text, _cx| (text == "foo.rs").then(|| "file:///tmp/foo.rs".into()),
cx,
);
assert_eq!(rendered.links.len(), 1);
assert_eq!(rendered.links[0].destination_url, "https://example.com");
}
#[gpui::test]
fn test_context_menu_link_initial_state(cx: &mut TestAppContext) {
struct TestWindow;

View file

@ -1,71 +1,17 @@
use super::{HoverTarget, HoveredWord, TerminalView};
use anyhow::{Context as _, Result};
use editor::Editor;
use gpui::{App, AppContext, Context, Task, TaskExt, WeakEntity, Window};
use itertools::Itertools;
use project::{Entry, Metadata};
use gpui::{Context, Task, TaskExt, WeakEntity, Window};
use std::path::PathBuf;
use terminal::PathLikeTarget;
use util::{
ResultExt, debug_panic,
paths::{PathStyle, PathWithPosition, normalize_lexically},
rel_path::RelPath,
use util::{ResultExt, debug_panic};
#[cfg(not(test))]
use workspace::path_link::possible_open_target;
#[cfg(test)]
use workspace::path_link::{
BackgroundFsChecks, OpenTargetFoundBy, possible_open_target_with_fs_checks,
};
use workspace::{OpenOptions, OpenVisible, Workspace};
/// The way we found the open target. This is important to have for test assertions.
/// For example, remote projects never look in the file system.
#[cfg(test)]
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum OpenTargetFoundBy {
WorktreeExact,
WorktreeScan,
FileSystemBackground,
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum BackgroundFsChecks {
Enabled,
Disabled,
}
#[derive(Debug, Clone)]
enum OpenTarget {
Worktree(PathWithPosition, Entry, #[cfg(test)] OpenTargetFoundBy),
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,
}
}
#[cfg(test)]
fn found_by(&self) -> OpenTargetFoundBy {
match self {
OpenTarget::Worktree(.., found_by) => *found_by,
OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground,
}
}
}
use workspace::{OpenOptions, OpenVisible, Workspace, path_link::OpenTarget};
pub(super) fn hover_path_like_target(
workspace: &WeakEntity<Workspace>,
@ -96,11 +42,19 @@ fn possible_hover_target(
cx: &mut Context<TerminalView>,
#[cfg(test)] background_fs_checks: BackgroundFsChecks,
) -> Task<()> {
#[cfg(not(test))]
let file_to_open_task = possible_open_target(
workspace,
path_like_target,
&path_like_target.maybe_path,
path_like_target.terminal_dir.as_deref(),
cx,
);
#[cfg(test)]
let file_to_open_task = possible_open_target_with_fs_checks(
workspace,
&path_like_target.maybe_path,
path_like_target.terminal_dir.as_deref(),
cx,
#[cfg(test)]
background_fs_checks,
);
cx.spawn(async move |terminal_view, cx| {
@ -122,297 +76,6 @@ fn possible_hover_target(
})
}
fn possible_open_target(
workspace: &WeakEntity<Workspace>,
path_like_target: &PathLikeTarget,
cx: &App,
#[cfg(test)] background_fs_checks: BackgroundFsChecks,
) -> Task<Option<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 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::<Vec<_>>();
// 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();
let mut is_cwd_in_worktree = false;
let mut open_target = None;
'worktree_loop: for worktree in &worktree_candidates {
let worktree_root = worktree.read(cx).abs_path();
let mut paths_to_check = Vec::with_capacity(potential_paths.len());
let relative_cwd = cwd
.and_then(|cwd| cwd.strip_prefix(&worktree_root).ok())
.and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok())
.and_then(|cwd_stripped| {
(cwd_stripped.as_ref() != RelPath::empty()).then(|| {
is_cwd_in_worktree = true;
cwd_stripped
})
});
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) => {
open_target = Some(OpenTarget::Worktree(
root_path_with_position,
root_entry.clone(),
#[cfg(test)]
OpenTargetFoundBy::WorktreeExact,
));
break 'worktree_loop;
}
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,
}
};
// Normalize the path by joining with cwd if available (handles `.` and `..` segments)
let normalized_path = if path_to_check.path.is_relative() {
relative_cwd.as_ref().and_then(|relative_cwd| {
let joined = relative_cwd
.as_ref()
.as_std_path()
.join(&path_to_check.path);
normalize_lexically(&joined).ok().and_then(|p| {
RelPath::new(&p, PathStyle::local())
.ok()
.map(std::borrow::Cow::into_owned)
})
})
} else {
None
};
let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok();
if !worktree.read(cx).is_single_file()
&& let Some(entry) = normalized_path
.as_ref()
.and_then(|p| worktree.read(cx).entry_for_path(p))
.or_else(|| {
original_path
.as_ref()
.and_then(|p| worktree.read(cx).entry_for_path(p.as_ref()))
})
{
open_target = Some(OpenTarget::Worktree(
PathWithPosition {
path: worktree.read(cx).absolutize(&entry.path),
row: path_to_check.row,
column: path_to_check.column,
},
entry.clone(),
#[cfg(test)]
OpenTargetFoundBy::WorktreeExact,
));
break 'worktree_loop;
}
paths_to_check.push(path_to_check);
}
if !paths_to_check.is_empty() {
worktree_paths_to_check.push((worktree.clone(), paths_to_check));
}
}
#[cfg(not(test))]
let enable_background_fs_checks = workspace.read(cx).project().read(cx).is_local();
#[cfg(test)]
let enable_background_fs_checks = background_fs_checks == BackgroundFsChecks::Enabled;
if open_target.is_some() {
// We we want to prefer open targets found via background fs checks over worktree matches,
// however we can return early if either:
// - This is a remote project, or
// - If the terminal working directory is inside of at least one worktree
if !enable_background_fs_checks || is_cwd_in_worktree {
return Task::ready(open_target);
}
}
// Before entire worktree traversal(s), make an attempt to do FS checks if available.
let fs_paths_to_check =
if enable_background_fs_checks {
let fs_cwd_paths_to_check = cwd
.iter()
.flat_map(|cwd| {
let mut paths_to_check = Vec::new();
for path_to_check in &potential_paths {
let maybe_path = &path_to_check.path;
if path_to_check.path.is_relative() {
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<_>>();
fs_cwd_paths_to_check
.into_iter()
.chain(
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() {
for worktree in &worktree_candidates {
if !worktree.read(cx).is_single_file() {
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::<Vec<_>>(),
)
.collect()
} else {
Vec::new()
};
let fs = workspace.read(cx).project().read(cx).fs().clone();
let background_fs_checks_task = 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()
{
if open_target
.as_ref()
.map(|open_target| open_target.path().path != fs_path_to_check)
.unwrap_or(true)
{
path_to_check.path = fs_path_to_check;
return Some(OpenTarget::File(path_to_check, metadata));
}
break;
}
}
open_target
});
cx.spawn(async move |cx| {
background_fs_checks_task.await.or_else(|| {
for (worktree, worktree_paths_to_check) in worktree_paths_to_check {
if let Some(found_entry) =
worktree.update(cx, |worktree, _| -> Option<OpenTarget> {
let traversal =
worktree.traverse_from_path(true, true, false, RelPath::empty());
for entry in traversal {
if let Some(path_in_worktree) =
worktree_paths_to_check.iter().find(|path_to_check| {
RelPath::new(&path_to_check.path, PathStyle::local())
.is_ok_and(|path| entry.path.ends_with(&path))
})
{
return Some(OpenTarget::Worktree(
PathWithPosition {
path: worktree.absolutize(&entry.path),
row: path_in_worktree.row,
column: path_in_worktree.column,
},
entry.clone(),
#[cfg(test)]
OpenTargetFoundBy::WorktreeScan,
));
}
}
None
})
{
return Some(found_entry);
}
}
None
})
})
}
pub(super) fn open_path_like_target(
workspace: &WeakEntity<Workspace>,
terminal_view: &mut TerminalView,
@ -455,13 +118,25 @@ fn possibly_open_target(
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,
#[cfg(test)]
background_fs_checks,
)
#[cfg(not(test))]
{
possible_open_target(
&workspace,
&path_like_target.maybe_path,
path_like_target.terminal_dir.as_deref(),
cx,
)
}
#[cfg(test)]
{
possible_open_target_with_fs_checks(
&workspace,
&path_like_target.maybe_path,
path_like_target.terminal_dir.as_deref(),
cx,
background_fs_checks,
)
}
})?
.await
else {
@ -530,7 +205,7 @@ fn possibly_open_target(
#[cfg(test)]
mod tests {
use super::*;
use gpui::TestAppContext;
use gpui::{AppContext as _, TestAppContext};
use project::Project;
use serde_json::json;
use std::path::{Path, PathBuf};
@ -540,6 +215,7 @@ mod tests {
terminal_settings::{AlternateScroll, CursorShape},
};
use util::path;
use util::paths::PathStyle;
use workspace::{AppState, MultiWorkspace};
async fn init_test(

View file

@ -36,6 +36,7 @@ clock.workspace = true
collections.workspace = true
component.workspace = true
db.workspace = true
dirs.workspace = true
futures-lite.workspace = true
fs.workspace = true
futures.workspace = true

View file

@ -0,0 +1,422 @@
use crate::Workspace;
use gpui::{App, AppContext, Task, WeakEntity};
use itertools::Itertools;
use project::{Entry, Metadata};
use std::path::{Path, PathBuf};
use util::{
paths::{PathStyle, PathWithPosition, normalize_lexically},
rel_path::RelPath,
};
#[cfg(any(test, feature = "test-support"))]
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum OpenTargetFoundBy {
WorktreeExact,
WorktreeScan,
FileSystemBackground,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum BackgroundFsChecks {
Enabled,
Disabled,
}
#[derive(Debug, Clone)]
pub enum OpenTarget {
Worktree(
PathWithPosition,
Entry,
#[cfg(any(test, feature = "test-support"))] OpenTargetFoundBy,
),
File(PathWithPosition, Metadata),
}
impl OpenTarget {
pub fn is_file(&self) -> bool {
match self {
OpenTarget::Worktree(_, entry, ..) => entry.is_file(),
OpenTarget::File(_, metadata) => !metadata.is_dir,
}
}
pub fn is_dir(&self) -> bool {
match self {
OpenTarget::Worktree(_, entry, ..) => entry.is_dir(),
OpenTarget::File(_, metadata) => metadata.is_dir,
}
}
pub fn path(&self) -> &PathWithPosition {
match self {
OpenTarget::Worktree(path, ..) => path,
OpenTarget::File(path, _) => path,
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn found_by(&self) -> OpenTargetFoundBy {
match self {
OpenTarget::Worktree(.., found_by) => *found_by,
OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground,
}
}
}
pub fn sanitize_path_text(text: &str) -> &str {
let start = first_unbalanced_open_paren(text).unwrap_or(0);
let mut sanitized = &text[start..];
let (open_parens, mut close_parens) =
sanitized
.chars()
.fold((0, 0), |(opens, closes), character| match character {
'(' => (opens + 1, closes),
')' => (opens, closes + 1),
_ => (opens, closes),
});
while let Some(last_char) = sanitized.chars().last() {
let should_remove = match last_char {
'.' | ',' | ':' | ';' => true,
'(' => true,
')' if close_parens > open_parens => {
close_parens -= 1;
true
}
_ => false,
};
if should_remove {
sanitized = &sanitized[..sanitized.len() - last_char.len_utf8()];
} else {
break;
}
}
sanitized
}
/// Returns the byte offset just past the first unbalanced `(` in `text`, or
/// `None` if all parentheses are balanced.
pub fn first_unbalanced_open_paren(text: &str) -> Option<usize> {
let mut balance: i32 = 0;
let mut first_unmatched = None;
for (index, character) in text.char_indices() {
match character {
'(' => {
if balance == 0 {
first_unmatched = Some(index + character.len_utf8());
}
balance += 1;
}
')' => {
balance -= 1;
if balance <= 0 {
balance = 0;
first_unmatched = None;
}
}
_ => {}
}
}
first_unmatched.filter(|_| balance > 0)
}
pub fn possible_open_target(
workspace: &WeakEntity<Workspace>,
maybe_path: &str,
cwd: Option<&Path>,
cx: &App,
) -> Task<Option<OpenTarget>> {
possible_open_target_internal(workspace, maybe_path, cwd, cx, None)
}
#[cfg(any(test, feature = "test-support"))]
pub fn possible_open_target_with_fs_checks(
workspace: &WeakEntity<Workspace>,
maybe_path: &str,
cwd: Option<&Path>,
cx: &App,
background_fs_checks: BackgroundFsChecks,
) -> Task<Option<OpenTarget>> {
possible_open_target_internal(workspace, maybe_path, cwd, cx, Some(background_fs_checks))
}
fn possible_open_target_internal(
workspace: &WeakEntity<Workspace>,
maybe_path: &str,
cwd: Option<&Path>,
cx: &App,
background_fs_checks: Option<BackgroundFsChecks>,
) -> Task<Option<OpenTarget>> {
let Some(workspace) = workspace.upgrade() else {
return Task::ready(None);
};
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.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) {
Some(cwd_child) => cwd_child.components().count(),
None => usize::MAX,
}
})
.collect::<Vec<_>>();
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);
}
let mut worktree_paths_to_check = Vec::new();
let mut is_cwd_in_worktree = false;
let mut open_target = None;
'worktree_loop: for worktree in &worktree_candidates {
let worktree_root = worktree.read(cx).abs_path();
let mut paths_to_check = Vec::with_capacity(potential_paths.len());
let relative_cwd = cwd
.and_then(|cwd| cwd.strip_prefix(&worktree_root).ok())
.and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok())
.and_then(|cwd_stripped| {
(cwd_stripped.as_ref() != RelPath::empty()).then(|| {
is_cwd_in_worktree = true;
cwd_stripped
})
});
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) => {
open_target = Some(OpenTarget::Worktree(
root_path_with_position,
root_entry.clone(),
#[cfg(any(test, feature = "test-support"))]
OpenTargetFoundBy::WorktreeExact,
));
break 'worktree_loop;
}
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,
}
};
let normalized_path = if path_to_check.path.is_relative() {
relative_cwd.as_ref().and_then(|relative_cwd| {
let joined = relative_cwd
.as_ref()
.as_std_path()
.join(&path_to_check.path);
normalize_lexically(&joined).ok().and_then(|path| {
RelPath::new(&path, PathStyle::local())
.ok()
.map(std::borrow::Cow::into_owned)
})
})
} else {
None
};
let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok();
if !worktree.read(cx).is_single_file()
&& let Some(entry) = normalized_path
.as_ref()
.and_then(|path| worktree.read(cx).entry_for_path(path))
.or_else(|| {
original_path
.as_ref()
.and_then(|path| worktree.read(cx).entry_for_path(path.as_ref()))
})
{
open_target = Some(OpenTarget::Worktree(
PathWithPosition {
path: worktree.read(cx).absolutize(&entry.path),
row: path_to_check.row,
column: path_to_check.column,
},
entry.clone(),
#[cfg(any(test, feature = "test-support"))]
OpenTargetFoundBy::WorktreeExact,
));
break 'worktree_loop;
}
paths_to_check.push(path_to_check);
}
if !paths_to_check.is_empty() {
worktree_paths_to_check.push((worktree.clone(), paths_to_check));
}
}
let enable_background_fs_checks = background_fs_checks
.map(|background_fs_checks| background_fs_checks == BackgroundFsChecks::Enabled)
.unwrap_or_else(|| workspace.read(cx).project().read(cx).is_local());
if open_target.is_some() {
if !enable_background_fs_checks || is_cwd_in_worktree {
return Task::ready(open_target);
}
}
let fs_paths_to_check = if enable_background_fs_checks {
let fs_cwd_paths_to_check = cwd
.iter()
.flat_map(|cwd| {
let mut paths_to_check = Vec::new();
for path_to_check in &potential_paths {
let maybe_path = &path_to_check.path;
if path_to_check.path.is_relative() {
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<_>>();
fs_cwd_paths_to_check
.into_iter()
.chain(
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| Some(dirs::home_dir()?.join(stripped)))
{
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() {
for worktree in &worktree_candidates {
if !worktree.read(cx).is_single_file() {
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::<Vec<_>>(),
)
.collect()
} else {
Vec::new()
};
let fs = workspace.read(cx).project().read(cx).fs().clone();
let background_fs_checks_task = 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()
{
if open_target
.as_ref()
.map(|open_target| open_target.path().path != fs_path_to_check)
.unwrap_or(true)
{
path_to_check.path = fs_path_to_check;
return Some(OpenTarget::File(path_to_check, metadata));
}
break;
}
}
open_target
});
cx.spawn(async move |cx| {
background_fs_checks_task.await.or_else(|| {
for (worktree, worktree_paths_to_check) in worktree_paths_to_check {
if let Some(found_entry) =
worktree.update(cx, |worktree, _| -> Option<OpenTarget> {
let traversal =
worktree.traverse_from_path(true, true, false, RelPath::empty());
for entry in traversal {
if let Some(path_in_worktree) =
worktree_paths_to_check.iter().find(|path_to_check| {
RelPath::new(&path_to_check.path, PathStyle::local())
.is_ok_and(|path| entry.path.ends_with(&path))
})
{
return Some(OpenTarget::Worktree(
PathWithPosition {
path: worktree.absolutize(&entry.path),
row: path_in_worktree.row,
column: path_in_worktree.column,
},
entry.clone(),
#[cfg(any(test, feature = "test-support"))]
OpenTargetFoundBy::WorktreeScan,
));
}
}
None
})
{
return Some(found_entry);
}
}
None
})
})
}

View file

@ -13,6 +13,7 @@ pub mod pane_group;
pub mod path_list {
pub use util::path_list::{PathList, SerializedPathList};
}
pub mod path_link;
mod persistence;
pub mod searchable;
pub mod security_modal;