mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
77cbba9b1a
commit
f78f6da255
15 changed files with 1096 additions and 449 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -407,6 +407,7 @@ dependencies = [
|
||||||
"language_models",
|
"language_models",
|
||||||
"languages",
|
"languages",
|
||||||
"log",
|
"log",
|
||||||
|
"lru",
|
||||||
"lsp",
|
"lsp",
|
||||||
"markdown",
|
"markdown",
|
||||||
"menu",
|
"menu",
|
||||||
|
|
@ -22018,6 +22019,7 @@ dependencies = [
|
||||||
"collections",
|
"collections",
|
||||||
"component",
|
"component",
|
||||||
"db",
|
"db",
|
||||||
|
"dirs",
|
||||||
"fs",
|
"fs",
|
||||||
"futures 0.3.32",
|
"futures 0.3.32",
|
||||||
"futures-lite 1.13.0",
|
"futures-lite 1.13.0",
|
||||||
|
|
|
||||||
|
|
@ -622,6 +622,7 @@ linkify = "0.10.0"
|
||||||
libwebrtc = "0.3.26"
|
libwebrtc = "0.3.26"
|
||||||
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
|
livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] }
|
||||||
log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] }
|
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" }
|
lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" }
|
||||||
mach2 = "0.5"
|
mach2 = "0.5"
|
||||||
markup5ever_rcdom = "0.3.0"
|
markup5ever_rcdom = "0.3.0"
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ pub enum MentionUri {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
abs_path: Option<PathBuf>,
|
abs_path: Option<PathBuf>,
|
||||||
line_range: RangeInclusive<u32>,
|
line_range: RangeInclusive<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
column: Option<u32>,
|
||||||
},
|
},
|
||||||
Fetch {
|
Fetch {
|
||||||
url: Url,
|
url: Url,
|
||||||
|
|
@ -105,6 +107,17 @@ impl MentionUri {
|
||||||
Ok(start_line..=end_line)
|
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 parse_absolute_path = |input: &str| -> Result<Self> {
|
||||||
let (path_input, fragment) = input
|
let (path_input, fragment) = input
|
||||||
.split_once('#')
|
.split_once('#')
|
||||||
|
|
@ -114,6 +127,7 @@ impl MentionUri {
|
||||||
return Ok(MentionUri::Selection {
|
return Ok(MentionUri::Selection {
|
||||||
abs_path: Some(path_input.into()),
|
abs_path: Some(path_input.into()),
|
||||||
line_range: fragment,
|
line_range: fragment,
|
||||||
|
column: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,10 +137,12 @@ impl MentionUri {
|
||||||
let line = row
|
let line = row
|
||||||
.checked_sub(1)
|
.checked_sub(1)
|
||||||
.context("Line numbers should be 1-based")?;
|
.context("Line numbers should be 1-based")?;
|
||||||
// TODO: Preserve column info too.
|
|
||||||
Ok(MentionUri::Selection {
|
Ok(MentionUri::Selection {
|
||||||
abs_path: Some(abs_path),
|
abs_path: Some(abs_path),
|
||||||
line_range: line..=line,
|
line_range: line..=line,
|
||||||
|
column: path_with_position
|
||||||
|
.column
|
||||||
|
.map(|column| column.saturating_sub(1)),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Ok(MentionUri::File { abs_path })
|
Ok(MentionUri::File { abs_path })
|
||||||
|
|
@ -156,8 +172,10 @@ impl MentionUri {
|
||||||
let path = normalized.as_ref();
|
let path = normalized.as_ref();
|
||||||
|
|
||||||
if let Some(fragment) = url.fragment() {
|
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);
|
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 {
|
Ok(Self::Symbol {
|
||||||
name,
|
name,
|
||||||
abs_path: path.into(),
|
abs_path: path.into(),
|
||||||
|
|
@ -167,6 +185,7 @@ impl MentionUri {
|
||||||
Ok(Self::Selection {
|
Ok(Self::Selection {
|
||||||
abs_path: Some(path.into()),
|
abs_path: Some(path.into()),
|
||||||
line_range,
|
line_range,
|
||||||
|
column,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if input.ends_with("/") {
|
} else if input.ends_with("/") {
|
||||||
|
|
@ -216,9 +235,11 @@ impl MentionUri {
|
||||||
.fragment()
|
.fragment()
|
||||||
.context("Missing fragment for untitled buffer selection")?;
|
.context("Missing fragment for untitled buffer selection")?;
|
||||||
let line_range = parse_line_range(fragment)?;
|
let line_range = parse_line_range(fragment)?;
|
||||||
|
validate_query_params(&url, &["column"])?;
|
||||||
Ok(Self::Selection {
|
Ok(Self::Selection {
|
||||||
abs_path: None,
|
abs_path: None,
|
||||||
line_range,
|
line_range,
|
||||||
|
column: parse_column(query_param(&url, "column")),
|
||||||
})
|
})
|
||||||
} else if let Some(name) = path.strip_prefix("/agent/symbol/") {
|
} else if let Some(name) = path.strip_prefix("/agent/symbol/") {
|
||||||
let fragment = url
|
let fragment = url
|
||||||
|
|
@ -245,13 +266,15 @@ impl MentionUri {
|
||||||
abs_path: path.into(),
|
abs_path: path.into(),
|
||||||
})
|
})
|
||||||
} else if path.starts_with("/agent/selection") {
|
} else if path.starts_with("/agent/selection") {
|
||||||
|
validate_query_params(&url, &["path", "column"])?;
|
||||||
let fragment = url.fragment().context("Missing fragment for selection")?;
|
let fragment = url.fragment().context("Missing fragment for selection")?;
|
||||||
let line_range = parse_line_range(fragment)?;
|
let line_range = parse_line_range(fragment)?;
|
||||||
let path =
|
let column = parse_column(query_param(&url, "column"));
|
||||||
single_query_param(&url, "path")?.context("Missing path for selection")?;
|
let path = query_param(&url, "path").context("Missing path for selection")?;
|
||||||
Ok(Self::Selection {
|
Ok(Self::Selection {
|
||||||
abs_path: Some(path.into()),
|
abs_path: Some(path.into()),
|
||||||
line_range,
|
line_range,
|
||||||
|
column,
|
||||||
})
|
})
|
||||||
} else if path.starts_with("/agent/terminal-selection") {
|
} else if path.starts_with("/agent/terminal-selection") {
|
||||||
let line_count = single_query_param(&url, "lines")?
|
let line_count = single_query_param(&url, "lines")?
|
||||||
|
|
@ -460,6 +483,7 @@ impl MentionUri {
|
||||||
abs_path,
|
abs_path,
|
||||||
name,
|
name,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut url = Url::parse("file:///").unwrap();
|
let mut url = Url::parse("file:///").unwrap();
|
||||||
url.set_path(&abs_path.to_string_lossy());
|
url.set_path(&abs_path.to_string_lossy());
|
||||||
|
|
@ -474,6 +498,7 @@ impl MentionUri {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path,
|
abs_path,
|
||||||
line_range,
|
line_range,
|
||||||
|
column,
|
||||||
} => {
|
} => {
|
||||||
let mut url = if let Some(path) = abs_path {
|
let mut url = if let Some(path) = abs_path {
|
||||||
let mut url = Url::parse("file:///").unwrap();
|
let mut url = Url::parse("file:///").unwrap();
|
||||||
|
|
@ -484,6 +509,10 @@ impl MentionUri {
|
||||||
url.set_path("/agent/untitled-buffer");
|
url.set_path("/agent/untitled-buffer");
|
||||||
url
|
url
|
||||||
};
|
};
|
||||||
|
if let Some(column) = column {
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("column", &(column + 1).to_string());
|
||||||
|
}
|
||||||
url.set_fragment(Some(&format!(
|
url.set_fragment(Some(&format!(
|
||||||
"L{}:{}",
|
"L{}:{}",
|
||||||
line_range.start() + 1,
|
line_range.start() + 1,
|
||||||
|
|
@ -564,6 +593,11 @@ fn default_include_errors() -> bool {
|
||||||
true
|
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>> {
|
fn single_query_param(url: &Url, name: &'static str) -> Result<Option<String>> {
|
||||||
let pairs = url.query_pairs().collect::<Vec<_>>();
|
let pairs = url.query_pairs().collect::<Vec<_>>();
|
||||||
match pairs.as_slice() {
|
match pairs.as_slice() {
|
||||||
|
|
@ -698,6 +732,7 @@ mod tests {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
name,
|
name,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path, Path::new(path!("/path/to/file.rs")));
|
assert_eq!(path, Path::new(path!("/path/to/file.rs")));
|
||||||
assert_eq!(name, "MySymbol");
|
assert_eq!(name, "MySymbol");
|
||||||
|
|
@ -717,6 +752,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
||||||
assert_eq!(line_range.start(), &4);
|
assert_eq!(line_range.start(), &4);
|
||||||
|
|
@ -748,6 +784,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: None,
|
abs_path: None,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(line_range.start(), &0);
|
assert_eq!(line_range.start(), &0);
|
||||||
assert_eq!(line_range.end(), &9);
|
assert_eq!(line_range.end(), &9);
|
||||||
|
|
@ -895,6 +932,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
|
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
|
||||||
assert_eq!(line_range.start(), &41);
|
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]
|
#[test]
|
||||||
fn test_parse_absolute_file_path_with_fragment_line() {
|
fn test_parse_absolute_file_path_with_fragment_line() {
|
||||||
let file_path = "/path/to/file.rs#L42";
|
let file_path = "/path/to/file.rs#L42";
|
||||||
|
|
@ -912,6 +973,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
|
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
|
||||||
assert_eq!(line_range.start(), &41);
|
assert_eq!(line_range.start(), &41);
|
||||||
|
|
@ -941,6 +1003,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path.as_ref().unwrap(),
|
path.as_ref().unwrap(),
|
||||||
|
|
@ -961,6 +1024,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path.as_ref().unwrap(),
|
path.as_ref().unwrap(),
|
||||||
|
|
@ -993,6 +1057,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
|
assert_eq!(path.as_ref().unwrap(), Path::new("/path/to/file.rs"));
|
||||||
assert_eq!(line_range.start(), &41);
|
assert_eq!(line_range.start(), &41);
|
||||||
|
|
@ -1010,6 +1075,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path.as_ref().unwrap(),
|
path.as_ref().unwrap(),
|
||||||
|
|
@ -1031,6 +1097,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
||||||
assert_eq!(line_range.start(), &1871);
|
assert_eq!(line_range.start(), &1871);
|
||||||
|
|
@ -1048,6 +1115,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
||||||
assert_eq!(line_range.start(), &9);
|
assert_eq!(line_range.start(), &9);
|
||||||
|
|
@ -1063,6 +1131,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
assert_eq!(path.as_ref().unwrap(), Path::new(path!("/path/to/file.rs")));
|
||||||
assert_eq!(line_range.start(), &9);
|
assert_eq!(line_range.start(), &9);
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ language.workspace = true
|
||||||
language_model.workspace = true
|
language_model.workspace = true
|
||||||
language_models.workspace = true
|
language_models.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
lru.workspace = true
|
||||||
lsp.workspace = true
|
lsp.workspace = true
|
||||||
markdown.workspace = true
|
markdown.workspace = true
|
||||||
menu.workspace = true
|
menu.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,12 @@ use ::ui::IconName;
|
||||||
use agent_client_protocol::schema as acp;
|
use agent_client_protocol::schema as acp;
|
||||||
use agent_settings::{AgentProfileId, AgentSettings};
|
use agent_settings::{AgentProfileId, AgentSettings};
|
||||||
use command_palette_hooks::CommandPaletteFilter;
|
use command_palette_hooks::CommandPaletteFilter;
|
||||||
|
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
|
||||||
use feature_flags::FeatureFlagAppExt as _;
|
use feature_flags::FeatureFlagAppExt as _;
|
||||||
use fs::Fs;
|
use fs::Fs;
|
||||||
use gpui::{
|
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::{
|
use language::{
|
||||||
LanguageRegistry,
|
LanguageRegistry,
|
||||||
|
|
@ -57,6 +59,7 @@ use language_model::{
|
||||||
};
|
};
|
||||||
use project::{AgentId, DisableAiSettings};
|
use project::{AgentId, DisableAiSettings};
|
||||||
use prompt_store::{PromptBuilder, rules_to_skills_migration};
|
use prompt_store::{PromptBuilder, rules_to_skills_migration};
|
||||||
|
use rope::Point;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide};
|
use settings::{LanguageModelSelection, Settings as _, SettingsStore, SidebarSide};
|
||||||
|
|
@ -112,6 +115,42 @@ pub(crate) fn resolve_agent_image(
|
||||||
None
|
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";
|
pub const DEFAULT_THREAD_TITLE: &str = "New Agent Thread";
|
||||||
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
|
const PARALLEL_AGENT_LAYOUT_BACKFILL_KEY: &str = "parallel_agent_layout_backfilled";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,16 @@ use language_model::{LanguageModelCompletionError, LanguageModelRegistry};
|
||||||
use markdown::{
|
use markdown::{
|
||||||
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
|
CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownStyle,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::{Mutex, RwLock};
|
||||||
use project::{AgentId, AgentServerStore, Project, ProjectEntryId};
|
use project::{AgentId, AgentServerStore, Project, ProjectEntryId, ProjectPath};
|
||||||
use prompt_store::{PromptId, PromptStore};
|
use prompt_store::{PromptId, PromptStore};
|
||||||
|
|
||||||
use crate::message_editor::SessionCapabilities;
|
use crate::message_editor::SessionCapabilities;
|
||||||
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
|
use crate::{AgentThreadSource, DEFAULT_THREAD_TITLE, resolve_agent_image};
|
||||||
|
use lru::LruCache;
|
||||||
use rope::Point;
|
use rope::Point;
|
||||||
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay};
|
use settings::{NotifyWhenAgentWaiting, Settings as _, SettingsStore, ThinkingBlockDisplay};
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
@ -61,11 +63,17 @@ use ui::{
|
||||||
KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
|
KeyBinding, PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, WithScrollbar, prelude::*,
|
||||||
right_click_menu,
|
right_click_menu,
|
||||||
};
|
};
|
||||||
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
|
use util::{
|
||||||
use util::{debug_panic, defer};
|
ResultExt, debug_panic, defer,
|
||||||
|
paths::{PathStyle, PathWithPosition},
|
||||||
|
rel_path::RelPath,
|
||||||
|
size::format_file_size,
|
||||||
|
time::duration_alt_display,
|
||||||
|
};
|
||||||
use workspace::PathList;
|
use workspace::PathList;
|
||||||
use workspace::{
|
use workspace::{
|
||||||
CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
|
CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
|
||||||
|
path_link::sanitize_path_text,
|
||||||
};
|
};
|
||||||
use zed_actions::agent::{Chat, ToggleModelSelector};
|
use zed_actions::agent::{Chat, ToggleModelSelector};
|
||||||
use zed_actions::assistant::OpenRulesLibrary;
|
use zed_actions::assistant::OpenRulesLibrary;
|
||||||
|
|
@ -509,6 +517,9 @@ pub struct ConversationView {
|
||||||
/// causes mermaid diagrams to re-render).
|
/// causes mermaid diagrams to re-render).
|
||||||
last_theme_id: Option<String>,
|
last_theme_id: Option<String>,
|
||||||
draft_prompt_persist_task: Option<Task<()>>,
|
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>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -707,7 +718,8 @@ impl ConversationView {
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let agent_server_store = project.read(cx).agent_server_store().clone();
|
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::agent_ui_font_size_changed),
|
||||||
cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
|
cx.observe_global_in::<SettingsStore>(window, Self::invalidate_mermaid_caches),
|
||||||
cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
|
cx.observe_global_in::<AgentUiFontSize>(window, Self::agent_ui_font_size_changed),
|
||||||
|
|
@ -718,6 +730,20 @@ impl ConversationView {
|
||||||
Self::handle_agent_servers_updated,
|
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| {
|
cx.on_release(|this, cx| {
|
||||||
if let Some(connected) = this.as_connected() {
|
if let Some(connected) = this.as_connected() {
|
||||||
|
|
@ -764,6 +790,7 @@ impl ConversationView {
|
||||||
auth_task: None,
|
auth_task: None,
|
||||||
last_theme_id: Some(cx.theme().id.clone()),
|
last_theme_id: Some(cx.theme().id.clone()),
|
||||||
draft_prompt_persist_task: None,
|
draft_prompt_persist_task: None,
|
||||||
|
code_span_resolver,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
}
|
}
|
||||||
|
|
@ -1218,6 +1245,7 @@ impl ConversationView {
|
||||||
session_capabilities,
|
session_capabilities,
|
||||||
resumed_without_history,
|
resumed_without_history,
|
||||||
self.project.downgrade(),
|
self.project.downgrade(),
|
||||||
|
self.code_span_resolver.clone(),
|
||||||
self.thread_store.clone(),
|
self.thread_store.clone(),
|
||||||
self.prompt_store.clone(),
|
self.prompt_store.clone(),
|
||||||
initial_content,
|
initial_content,
|
||||||
|
|
@ -2511,7 +2539,7 @@ impl ConversationView {
|
||||||
markdown,
|
markdown,
|
||||||
style,
|
style,
|
||||||
&self.workspace,
|
&self.workspace,
|
||||||
&self.project.downgrade(),
|
&self.code_span_resolver,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3118,20 +3146,12 @@ fn render_agent_markdown(
|
||||||
markdown: Entity<Markdown>,
|
markdown: Entity<Markdown>,
|
||||||
style: MarkdownStyle,
|
style: MarkdownStyle,
|
||||||
workspace: &WeakEntity<Workspace>,
|
workspace: &WeakEntity<Workspace>,
|
||||||
project: &WeakEntity<Project>,
|
code_span_resolver: &AgentCodeSpanResolver,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> MarkdownElement {
|
) -> MarkdownElement {
|
||||||
let workspace = workspace.clone();
|
let workspace = workspace.clone();
|
||||||
let worktree_roots: Vec<PathBuf> = project
|
let worktree_roots = code_span_resolver.worktree_roots(cx);
|
||||||
.upgrade()
|
let resolver = code_span_resolver.clone();
|
||||||
.map(|project| {
|
|
||||||
project
|
|
||||||
.read(cx)
|
|
||||||
.visible_worktrees(cx)
|
|
||||||
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
MarkdownElement::new(markdown, style)
|
MarkdownElement::new(markdown, style)
|
||||||
.code_block_renderer(markdown::CodeBlockRenderer::Default {
|
.code_block_renderer(markdown::CodeBlockRenderer::Default {
|
||||||
copy_button_visibility: markdown::CopyButtonVisibility::VisibleOnHover,
|
copy_button_visibility: markdown::CopyButtonVisibility::VisibleOnHover,
|
||||||
|
|
@ -3142,6 +3162,175 @@ fn render_agent_markdown(
|
||||||
.on_url_click(move |text, window, cx| {
|
.on_url_click(move |text, window, cx| {
|
||||||
thread_view::open_link(text, &workspace, 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(
|
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]
|
#[gpui::test]
|
||||||
async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
|
async fn test_notification_for_stop_event(cx: &mut TestAppContext) {
|
||||||
init_test(cx);
|
init_test(cx);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
|
DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
|
||||||
agent_configuration::configure_context_server_modal::default_markdown_style,
|
agent_configuration::configure_context_server_modal::default_markdown_style,
|
||||||
|
open_abs_path_at_point,
|
||||||
thread_metadata_store::{ThreadId, ThreadMetadataStore},
|
thread_metadata_store::{ThreadId, ThreadMetadataStore},
|
||||||
};
|
};
|
||||||
use agent_client_protocol::schema as acp;
|
use agent_client_protocol::schema as acp;
|
||||||
|
|
@ -330,6 +331,10 @@ pub struct ThreadView {
|
||||||
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
|
pub add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||||
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
|
pub thinking_effort_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||||
pub project: WeakEntity<Project>,
|
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_external_source_prompt_warning: bool,
|
||||||
pub show_codex_windows_warning: bool,
|
pub show_codex_windows_warning: bool,
|
||||||
pub multi_root_callout_dismissed: bool,
|
pub multi_root_callout_dismissed: bool,
|
||||||
|
|
@ -382,6 +387,7 @@ impl ThreadView {
|
||||||
session_capabilities: SharedSessionCapabilities,
|
session_capabilities: SharedSessionCapabilities,
|
||||||
resumed_without_history: bool,
|
resumed_without_history: bool,
|
||||||
project: WeakEntity<Project>,
|
project: WeakEntity<Project>,
|
||||||
|
code_span_resolver: AgentCodeSpanResolver,
|
||||||
thread_store: Option<Entity<ThreadStore>>,
|
thread_store: Option<Entity<ThreadStore>>,
|
||||||
prompt_store: Option<Entity<PromptStore>>,
|
prompt_store: Option<Entity<PromptStore>>,
|
||||||
initial_content: Option<AgentInitialContent>,
|
initial_content: Option<AgentInitialContent>,
|
||||||
|
|
@ -449,6 +455,23 @@ impl ThreadView {
|
||||||
&& project.upgrade().is_some_and(|p| p.read(cx).is_local())
|
&& project.upgrade().is_some_and(|p| p.read(cx).is_local())
|
||||||
&& agent_id.as_ref() == "Codex";
|
&& 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 title_editor = {
|
||||||
let metadata = ThreadMetadataStore::try_global(cx)
|
let metadata = ThreadMetadataStore::try_global(cx)
|
||||||
.and_then(|store| store.read(cx).entry(root_thread_id).cloned());
|
.and_then(|store| store.read(cx).entry(root_thread_id).cloned());
|
||||||
|
|
@ -601,6 +624,7 @@ impl ThreadView {
|
||||||
add_context_menu_handle: PopoverMenuHandle::default(),
|
add_context_menu_handle: PopoverMenuHandle::default(),
|
||||||
thinking_effort_menu_handle: PopoverMenuHandle::default(),
|
thinking_effort_menu_handle: PopoverMenuHandle::default(),
|
||||||
project,
|
project,
|
||||||
|
code_span_resolver,
|
||||||
show_external_source_prompt_warning,
|
show_external_source_prompt_warning,
|
||||||
show_codex_windows_warning,
|
show_codex_windows_warning,
|
||||||
multi_root_callout_dismissed: false,
|
multi_root_callout_dismissed: false,
|
||||||
|
|
@ -8701,7 +8725,13 @@ impl ThreadView {
|
||||||
style: MarkdownStyle,
|
style: MarkdownStyle,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> MarkdownElement {
|
) -> 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 {
|
fn create_copy_button(&self, message: impl Into<String>) -> impl IntoElement {
|
||||||
|
|
@ -9372,39 +9402,27 @@ pub(crate) fn open_link(
|
||||||
abs_path: path,
|
abs_path: path,
|
||||||
line_range,
|
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),
|
abs_path: Some(path),
|
||||||
line_range,
|
line_range,
|
||||||
|
column,
|
||||||
} => {
|
} => {
|
||||||
let project = workspace.project();
|
open_abs_path_at_point(
|
||||||
let Some(path) =
|
workspace,
|
||||||
project.update(cx, |project, cx| project.find_project_path(path, cx))
|
path,
|
||||||
else {
|
Point::new(*line_range.start(), column.unwrap_or(0)),
|
||||||
return;
|
window,
|
||||||
};
|
cx,
|
||||||
|
);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
MentionUri::Selection { abs_path: None, .. } => {}
|
MentionUri::Selection { abs_path: None, .. } => {}
|
||||||
MentionUri::Thread { id, name } => {
|
MentionUri::Thread { id, name } => {
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ impl MentionSet {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: Some(abs_path),
|
abs_path: Some(abs_path),
|
||||||
line_range,
|
line_range,
|
||||||
|
..
|
||||||
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
|
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
|
||||||
MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
|
MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
|
||||||
"Untitled buffer selection mentions are not supported for paste"
|
"Untitled buffer selection mentions are not supported for paste"
|
||||||
|
|
@ -570,6 +571,7 @@ impl MentionSet {
|
||||||
let uri = MentionUri::Selection {
|
let uri = MentionUri::Selection {
|
||||||
abs_path: abs_path.clone(),
|
abs_path: abs_path.clone(),
|
||||||
line_range: line_range.clone(),
|
line_range: line_range.clone(),
|
||||||
|
column: None,
|
||||||
};
|
};
|
||||||
let crease = crease_for_mention(
|
let crease = crease_for_mention(
|
||||||
selection_name(abs_path.as_deref(), &line_range).into(),
|
selection_name(abs_path.as_deref(), &line_range).into(),
|
||||||
|
|
@ -805,6 +807,7 @@ mod tests {
|
||||||
MentionUri::Selection {
|
MentionUri::Selection {
|
||||||
abs_path: Some(path!("/project/file.rs").into()),
|
abs_path: Some(path!("/project/file.rs").into()),
|
||||||
line_range: 1..=2,
|
line_range: 1..=2,
|
||||||
|
column: None,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
http_client,
|
http_client,
|
||||||
|
|
|
||||||
|
|
@ -1119,6 +1119,7 @@ impl MessageEditor {
|
||||||
let mention_uri = MentionUri::Selection {
|
let mention_uri = MentionUri::Selection {
|
||||||
abs_path: Some(file_path.clone()),
|
abs_path: Some(file_path.clone()),
|
||||||
line_range: line_range.clone(),
|
line_range: line_range.clone(),
|
||||||
|
column: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mention_text = mention_uri.as_link().to_string();
|
let mention_text = mention_uri.as_link().to_string();
|
||||||
|
|
@ -4397,10 +4398,12 @@ mod tests {
|
||||||
let first_uri = MentionUri::Selection {
|
let first_uri = MentionUri::Selection {
|
||||||
abs_path: Some(path!("/project/file.rs").into()),
|
abs_path: Some(path!("/project/file.rs").into()),
|
||||||
line_range: 0..=1,
|
line_range: 0..=1,
|
||||||
|
column: None,
|
||||||
};
|
};
|
||||||
let second_uri = MentionUri::Selection {
|
let second_uri = MentionUri::Selection {
|
||||||
abs_path: Some(path!("/project/file.rs").into()),
|
abs_path: Some(path!("/project/file.rs").into()),
|
||||||
line_range: 2..=3,
|
line_range: 2..=3,
|
||||||
|
column: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
source_message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
source_message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
||||||
|
|
@ -4558,10 +4561,12 @@ mod tests {
|
||||||
let first_uri = MentionUri::Selection {
|
let first_uri = MentionUri::Selection {
|
||||||
abs_path: Some(path!("/project/file.rs").into()),
|
abs_path: Some(path!("/project/file.rs").into()),
|
||||||
line_range: 0..=1,
|
line_range: 0..=1,
|
||||||
|
column: None,
|
||||||
};
|
};
|
||||||
let second_uri = MentionUri::Selection {
|
let second_uri = MentionUri::Selection {
|
||||||
abs_path: Some(path!("/project/file.rs").into()),
|
abs_path: Some(path!("/project/file.rs").into()),
|
||||||
line_range: 2..=3,
|
line_range: 2..=3,
|
||||||
|
column: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
let buffer_len = message_editor.update_in(&mut cx, |message_editor, window, cx| {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::{ops::RangeInclusive, path::PathBuf, time::Duration};
|
use std::{path::PathBuf, time::Duration};
|
||||||
|
|
||||||
use acp_thread::MentionUri;
|
use acp_thread::MentionUri;
|
||||||
use agent_client_protocol::schema as acp;
|
use agent_client_protocol::schema as acp;
|
||||||
use editor::{Editor, SelectionEffects, scroll::Autoscroll};
|
use editor::Editor;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
|
Animation, AnimationExt, AnyView, Context, IntoElement, TaskExt, WeakEntity, Window,
|
||||||
pulsating_between,
|
pulsating_between,
|
||||||
|
|
@ -15,6 +15,8 @@ use theme_settings::ThemeSettings;
|
||||||
use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
|
use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
|
||||||
use workspace::{OpenOptions, Workspace};
|
use workspace::{OpenOptions, Workspace};
|
||||||
|
|
||||||
|
use crate::open_abs_path_at_point;
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct MentionCrease {
|
pub struct MentionCrease {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
|
|
@ -165,12 +167,27 @@ fn open_mention_uri(
|
||||||
abs_path,
|
abs_path,
|
||||||
line_range,
|
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),
|
abs_path: Some(abs_path),
|
||||||
line_range,
|
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 } => {
|
MentionUri::Directory { abs_path } => {
|
||||||
reveal_in_project_panel(workspace, abs_path, cx);
|
reveal_in_project_panel(workspace, abs_path, cx);
|
||||||
|
|
@ -260,40 +277,23 @@ fn open_skill_file(
|
||||||
fn open_file(
|
fn open_file(
|
||||||
workspace: &mut Workspace,
|
workspace: &mut Workspace,
|
||||||
abs_path: PathBuf,
|
abs_path: PathBuf,
|
||||||
line_range: Option<RangeInclusive<u32>>,
|
point: Option<Point>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Workspace>,
|
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) =
|
if let Some(project_path) =
|
||||||
project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
|
project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
|
||||||
{
|
{
|
||||||
let item = workspace.open_path(project_path, None, true, window, cx);
|
workspace
|
||||||
if let Some(line_range) = line_range {
|
.open_path(project_path, None, true, window, cx)
|
||||||
window
|
.detach_and_log_err(cx);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
} else if abs_path.exists() {
|
} else if abs_path.exists() {
|
||||||
workspace
|
workspace
|
||||||
.open_abs_path(
|
.open_abs_path(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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.
|
/// If the callback returns `None`, the default link style will be used.
|
||||||
type LinkStyleCallback = Rc<dyn Fn(&str, &App) -> Option<TextStyleRefinement>>;
|
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 SourceClickCallback = Box<dyn Fn(usize, usize, &mut Window, &mut App) -> bool>;
|
||||||
type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
|
type CheckboxToggleCallback = Rc<dyn Fn(Range<usize>, bool, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
|
@ -1079,6 +1080,7 @@ pub struct MarkdownElement {
|
||||||
style: MarkdownStyle,
|
style: MarkdownStyle,
|
||||||
code_block_renderer: CodeBlockRenderer,
|
code_block_renderer: CodeBlockRenderer,
|
||||||
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
|
on_url_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
|
||||||
|
code_span_link: Option<CodeSpanLinkCallback>,
|
||||||
on_source_click: Option<SourceClickCallback>,
|
on_source_click: Option<SourceClickCallback>,
|
||||||
on_checkbox_toggle: Option<CheckboxToggleCallback>,
|
on_checkbox_toggle: Option<CheckboxToggleCallback>,
|
||||||
image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
|
image_resolver: Option<Box<dyn Fn(&str) -> Option<ImageSource>>>,
|
||||||
|
|
@ -1097,6 +1099,7 @@ impl MarkdownElement {
|
||||||
border: false,
|
border: false,
|
||||||
},
|
},
|
||||||
on_url_click: None,
|
on_url_click: None,
|
||||||
|
code_span_link: None,
|
||||||
on_source_click: None,
|
on_source_click: None,
|
||||||
on_checkbox_toggle: None,
|
on_checkbox_toggle: None,
|
||||||
image_resolver: None,
|
image_resolver: None,
|
||||||
|
|
@ -1139,6 +1142,14 @@ impl MarkdownElement {
|
||||||
self
|
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(
|
pub fn on_source_click(
|
||||||
mut self,
|
mut self,
|
||||||
handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
|
handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static,
|
||||||
|
|
@ -1173,6 +1184,41 @@ impl MarkdownElement {
|
||||||
self
|
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(
|
fn push_markdown_image(
|
||||||
&self,
|
&self,
|
||||||
builder: &mut MarkdownElementBuilder,
|
builder: &mut MarkdownElementBuilder,
|
||||||
|
|
@ -2013,6 +2059,7 @@ impl Element for MarkdownElement {
|
||||||
}
|
}
|
||||||
MarkdownTag::Link { dest_url, .. } => {
|
MarkdownTag::Link { dest_url, .. } => {
|
||||||
if builder.code_block_stack.is_empty() {
|
if builder.code_block_stack.is_empty() {
|
||||||
|
builder.link_depth += 1;
|
||||||
builder.push_link(dest_url.clone(), range.clone());
|
builder.push_link(dest_url.clone(), range.clone());
|
||||||
let style = self
|
let style = self
|
||||||
.style
|
.style
|
||||||
|
|
@ -2239,6 +2286,7 @@ impl Element for MarkdownElement {
|
||||||
MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
|
MarkdownTagEnd::Strikethrough => builder.pop_text_style(),
|
||||||
MarkdownTagEnd::Link => {
|
MarkdownTagEnd::Link => {
|
||||||
if builder.code_block_stack.is_empty() {
|
if builder.code_block_stack.is_empty() {
|
||||||
|
builder.link_depth = builder.link_depth.saturating_sub(1);
|
||||||
builder.pop_text_style()
|
builder.pop_text_style()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2273,9 +2321,12 @@ impl Element for MarkdownElement {
|
||||||
builder.push_text(text, range.clone());
|
builder.push_text(text, range.clone());
|
||||||
}
|
}
|
||||||
MarkdownEvent::Code => {
|
MarkdownEvent::Code => {
|
||||||
builder.push_text_style(self.style.inline_code.clone());
|
self.push_markdown_code_span(
|
||||||
builder.push_text(&parsed_markdown.source[range.clone()], range.clone());
|
&mut builder,
|
||||||
builder.pop_text_style();
|
&parsed_markdown.source[range.clone()],
|
||||||
|
range.clone(),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
MarkdownEvent::Html => {
|
MarkdownEvent::Html => {
|
||||||
let html = &parsed_markdown.source[range.clone()];
|
let html = &parsed_markdown.source[range.clone()];
|
||||||
|
|
@ -2293,6 +2344,19 @@ impl Element for MarkdownElement {
|
||||||
}
|
}
|
||||||
MarkdownEvent::InlineHtml => {
|
MarkdownEvent::InlineHtml => {
|
||||||
let html = &parsed_markdown.source[range.clone()];
|
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>") {
|
if html.starts_with("<code>") {
|
||||||
builder.push_text_style(self.style.inline_code.clone());
|
builder.push_text_style(self.style.inline_code.clone());
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2653,6 +2717,7 @@ struct MarkdownElementBuilder {
|
||||||
base_text_style: TextStyle,
|
base_text_style: TextStyle,
|
||||||
text_style_stack: Vec<TextStyleRefinement>,
|
text_style_stack: Vec<TextStyleRefinement>,
|
||||||
code_block_stack: Vec<Option<Arc<Language>>>,
|
code_block_stack: Vec<Option<Arc<Language>>>,
|
||||||
|
link_depth: usize,
|
||||||
list_stack: Vec<ListStackEntry>,
|
list_stack: Vec<ListStackEntry>,
|
||||||
table: TableState,
|
table: TableState,
|
||||||
syntax_theme: Arc<SyntaxTheme>,
|
syntax_theme: Arc<SyntaxTheme>,
|
||||||
|
|
@ -2691,6 +2756,7 @@ impl MarkdownElementBuilder {
|
||||||
base_text_style,
|
base_text_style,
|
||||||
text_style_stack: Vec::new(),
|
text_style_stack: Vec::new(),
|
||||||
code_block_stack: Vec::new(),
|
code_block_stack: Vec::new(),
|
||||||
|
link_depth: 0,
|
||||||
list_stack: Vec::new(),
|
list_stack: Vec::new(),
|
||||||
table: TableState::default(),
|
table: TableState::default(),
|
||||||
syntax_theme,
|
syntax_theme,
|
||||||
|
|
@ -3470,6 +3536,40 @@ mod tests {
|
||||||
render_markdown_with_language_registry(markdown, None, cx)
|
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(
|
fn render_markdown_with_language_registry(
|
||||||
markdown: &str,
|
markdown: &str,
|
||||||
language_registry: Option<Arc<LanguageRegistry>>,
|
language_registry: Option<Arc<LanguageRegistry>>,
|
||||||
|
|
@ -4105,6 +4205,50 @@ mod tests {
|
||||||
assert!(rendered.link_for_source_index(5).is_none());
|
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]
|
#[gpui::test]
|
||||||
fn test_context_menu_link_initial_state(cx: &mut TestAppContext) {
|
fn test_context_menu_link_initial_state(cx: &mut TestAppContext) {
|
||||||
struct TestWindow;
|
struct TestWindow;
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,17 @@
|
||||||
use super::{HoverTarget, HoveredWord, TerminalView};
|
use super::{HoverTarget, HoveredWord, TerminalView};
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use editor::Editor;
|
use editor::Editor;
|
||||||
use gpui::{App, AppContext, Context, Task, TaskExt, WeakEntity, Window};
|
use gpui::{Context, Task, TaskExt, WeakEntity, Window};
|
||||||
use itertools::Itertools;
|
|
||||||
use project::{Entry, Metadata};
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use terminal::PathLikeTarget;
|
use terminal::PathLikeTarget;
|
||||||
use util::{
|
use util::{ResultExt, debug_panic};
|
||||||
ResultExt, debug_panic,
|
#[cfg(not(test))]
|
||||||
paths::{PathStyle, PathWithPosition, normalize_lexically},
|
use workspace::path_link::possible_open_target;
|
||||||
rel_path::RelPath,
|
#[cfg(test)]
|
||||||
|
use workspace::path_link::{
|
||||||
|
BackgroundFsChecks, OpenTargetFoundBy, possible_open_target_with_fs_checks,
|
||||||
};
|
};
|
||||||
use workspace::{OpenOptions, OpenVisible, Workspace};
|
use workspace::{OpenOptions, OpenVisible, Workspace, path_link::OpenTarget};
|
||||||
|
|
||||||
/// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn hover_path_like_target(
|
pub(super) fn hover_path_like_target(
|
||||||
workspace: &WeakEntity<Workspace>,
|
workspace: &WeakEntity<Workspace>,
|
||||||
|
|
@ -96,11 +42,19 @@ fn possible_hover_target(
|
||||||
cx: &mut Context<TerminalView>,
|
cx: &mut Context<TerminalView>,
|
||||||
#[cfg(test)] background_fs_checks: BackgroundFsChecks,
|
#[cfg(test)] background_fs_checks: BackgroundFsChecks,
|
||||||
) -> Task<()> {
|
) -> Task<()> {
|
||||||
|
#[cfg(not(test))]
|
||||||
let file_to_open_task = possible_open_target(
|
let file_to_open_task = possible_open_target(
|
||||||
workspace,
|
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,
|
cx,
|
||||||
#[cfg(test)]
|
|
||||||
background_fs_checks,
|
background_fs_checks,
|
||||||
);
|
);
|
||||||
cx.spawn(async move |terminal_view, cx| {
|
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(
|
pub(super) fn open_path_like_target(
|
||||||
workspace: &WeakEntity<Workspace>,
|
workspace: &WeakEntity<Workspace>,
|
||||||
terminal_view: &mut TerminalView,
|
terminal_view: &mut TerminalView,
|
||||||
|
|
@ -455,13 +118,25 @@ fn possibly_open_target(
|
||||||
cx.spawn_in(window, async move |terminal_view, cx| {
|
cx.spawn_in(window, async move |terminal_view, cx| {
|
||||||
let Some(open_target) = terminal_view
|
let Some(open_target) = terminal_view
|
||||||
.update(cx, |_, cx| {
|
.update(cx, |_, cx| {
|
||||||
possible_open_target(
|
#[cfg(not(test))]
|
||||||
&workspace,
|
{
|
||||||
&path_like_target,
|
possible_open_target(
|
||||||
cx,
|
&workspace,
|
||||||
#[cfg(test)]
|
&path_like_target.maybe_path,
|
||||||
background_fs_checks,
|
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
|
.await
|
||||||
else {
|
else {
|
||||||
|
|
@ -530,7 +205,7 @@ fn possibly_open_target(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gpui::TestAppContext;
|
use gpui::{AppContext as _, TestAppContext};
|
||||||
use project::Project;
|
use project::Project;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -540,6 +215,7 @@ mod tests {
|
||||||
terminal_settings::{AlternateScroll, CursorShape},
|
terminal_settings::{AlternateScroll, CursorShape},
|
||||||
};
|
};
|
||||||
use util::path;
|
use util::path;
|
||||||
|
use util::paths::PathStyle;
|
||||||
use workspace::{AppState, MultiWorkspace};
|
use workspace::{AppState, MultiWorkspace};
|
||||||
|
|
||||||
async fn init_test(
|
async fn init_test(
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ clock.workspace = true
|
||||||
collections.workspace = true
|
collections.workspace = true
|
||||||
component.workspace = true
|
component.workspace = true
|
||||||
db.workspace = true
|
db.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
futures-lite.workspace = true
|
futures-lite.workspace = true
|
||||||
fs.workspace = true
|
fs.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|
|
||||||
422
crates/workspace/src/path_link.rs
Normal file
422
crates/workspace/src/path_link.rs
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ pub mod pane_group;
|
||||||
pub mod path_list {
|
pub mod path_list {
|
||||||
pub use util::path_list::{PathList, SerializedPathList};
|
pub use util::path_list::{PathList, SerializedPathList};
|
||||||
}
|
}
|
||||||
|
pub mod path_link;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
pub mod searchable;
|
pub mod searchable;
|
||||||
pub mod security_modal;
|
pub mod security_modal;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue