Parse and render markdown mention links from pasted text (#45426)
Closes [#ISSUE](https://github.com/zed-industries/zed/issues/45408) Release Notes: - Fixed mention links from pasted text. Recording: https://github.com/user-attachments/assets/e0d55562-c9a4-4798-be41-af8b8cd1f5d7
This commit is contained in:
1 parent
804caba60c
commit
592d21898d
2 files changed
+274
-2
No files matched your search
@@ -31,9 +31,10 @@ use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Wor
|
||||
use prompt_store::PromptStore;
|
||||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
|
||||
use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
|
||||
use util::paths::PathStyle;
|
||||
use util::{ResultExt, debug_panic};
|
||||
use workspace::{CollaboratorId, Workspace};
|
||||
use zed_actions::agent::{Chat, PasteRaw};
|
||||
@@ -735,13 +736,94 @@ impl MessageEditor {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle text paste with potential markdown mention links.
|
||||
// This must be checked BEFORE paste_images_as_context because that function
|
||||
// returns a task even when there are no images in the clipboard.
|
||||
if let Some(clipboard_text) = cx
|
||||
.read_from_clipboard()
|
||||
.and_then(|item| item.entries().first().cloned())
|
||||
.and_then(|entry| match entry {
|
||||
ClipboardEntry::String(text) => Some(text.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
let path_style = workspace.read(cx).project().read(cx).path_style(cx);
|
||||
|
||||
// Parse markdown mention links in format: [@name](uri)
|
||||
let parsed_mentions = parse_mention_links(&clipboard_text, path_style);
|
||||
|
||||
if !parsed_mentions.is_empty() {
|
||||
cx.stop_propagation();
|
||||
|
||||
let insertion_offset = self.editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
editor.selections.newest_anchor().start.to_offset(&snapshot)
|
||||
});
|
||||
|
||||
// Insert the raw text first
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.insert(&clipboard_text, window, cx);
|
||||
});
|
||||
|
||||
let supports_images = self.prompt_capabilities.borrow().image;
|
||||
let http_client = workspace.read(cx).client().http_client();
|
||||
|
||||
// Now create creases for each mention and load their content
|
||||
let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
|
||||
for (range, mention_uri) in parsed_mentions {
|
||||
let start_offset = insertion_offset.0 + range.start;
|
||||
let anchor = snapshot.anchor_before(MultiBufferOffset(start_offset));
|
||||
let content_len = range.end - range.start;
|
||||
|
||||
let Some((crease_id, tx)) = insert_crease_for_mention(
|
||||
anchor.excerpt_id,
|
||||
anchor.text_anchor,
|
||||
content_len,
|
||||
mention_uri.name().into(),
|
||||
mention_uri.icon_path(cx),
|
||||
None,
|
||||
self.editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Create the confirmation task based on the mention URI type.
|
||||
// This properly loads file content, fetches URLs, etc.
|
||||
let task = self.mention_set.update(cx, |mention_set, cx| {
|
||||
mention_set.confirm_mention_for_uri(
|
||||
mention_uri.clone(),
|
||||
supports_images,
|
||||
http_client.clone(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let task = cx
|
||||
.spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
|
||||
.shared();
|
||||
|
||||
self.mention_set.update(cx, |mention_set, _cx| {
|
||||
mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
|
||||
});
|
||||
|
||||
// Drop the tx after inserting to signal the crease is ready
|
||||
drop(tx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if self.prompt_capabilities.borrow().image
|
||||
&& let Some(task) =
|
||||
paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
|
||||
{
|
||||
task.detach();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall through to default editor paste
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -1283,6 +1365,69 @@ impl Addon for MessageEditorAddon {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses markdown mention links in the format `[@name](uri)` from text.
|
||||
/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
|
||||
fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
|
||||
let mut mentions = Vec::new();
|
||||
let mut search_start = 0;
|
||||
|
||||
while let Some(link_start) = text[search_start..].find("[@") {
|
||||
let absolute_start = search_start + link_start;
|
||||
|
||||
// Find the matching closing bracket for the name, handling nested brackets.
|
||||
// Start at the '[' character so find_matching_bracket can track depth correctly.
|
||||
let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
|
||||
search_start = absolute_start + 2;
|
||||
continue;
|
||||
};
|
||||
let name_end = absolute_start + name_end;
|
||||
|
||||
// Check for opening parenthesis immediately after
|
||||
if text.get(name_end + 1..name_end + 2) != Some("(") {
|
||||
search_start = name_end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the matching closing parenthesis for the URI, handling nested parens
|
||||
let uri_start = name_end + 2;
|
||||
let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
|
||||
search_start = uri_start;
|
||||
continue;
|
||||
};
|
||||
let uri_end = name_end + 1 + uri_end_relative;
|
||||
let link_end = uri_end + 1;
|
||||
|
||||
let uri_str = &text[uri_start..uri_end];
|
||||
|
||||
// Try to parse the URI as a MentionUri
|
||||
if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
|
||||
mentions.push((absolute_start..link_end, mention_uri));
|
||||
}
|
||||
|
||||
search_start = link_end;
|
||||
}
|
||||
|
||||
mentions
|
||||
}
|
||||
|
||||
/// Finds the position of the matching closing bracket, handling nested brackets.
|
||||
/// The input `text` should start with the opening bracket.
|
||||
/// Returns the index of the matching closing bracket relative to `text`.
|
||||
fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
|
||||
let mut depth = 0;
|
||||
for (index, character) in text.char_indices() {
|
||||
if character == open {
|
||||
depth += 1;
|
||||
} else if character == close {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||
@@ -1308,11 +1453,103 @@ mod tests {
|
||||
use workspace::{AppState, Item, Workspace};
|
||||
|
||||
use crate::acp::{
|
||||
message_editor::{Mention, MessageEditor},
|
||||
message_editor::{Mention, MessageEditor, parse_mention_links},
|
||||
thread_view::tests::init_test,
|
||||
};
|
||||
use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
|
||||
|
||||
#[test]
|
||||
fn test_parse_mention_links() {
|
||||
// Single file mention
|
||||
let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].0, 0..text.len());
|
||||
assert!(matches!(mentions[0].1, MentionUri::File { .. }));
|
||||
|
||||
// Multiple mentions
|
||||
let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 2);
|
||||
|
||||
// Text without mentions
|
||||
let text = "Just some regular text without mentions";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 0);
|
||||
|
||||
// Malformed mentions (should be skipped)
|
||||
let text = "[@incomplete](invalid://uri) and [@missing](";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 0);
|
||||
|
||||
// Mixed content with valid mention
|
||||
let text = "Before [@valid](file:///path/to/file) after";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].0.start, 7);
|
||||
|
||||
// HTTP URL mention (Fetch)
|
||||
let text = "Check out [@docs](https://example.com/docs) for more info";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
|
||||
|
||||
// Directory mention (trailing slash)
|
||||
let text = "[@src](file:///path/to/src/)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
|
||||
|
||||
// Multiple different mention types
|
||||
let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 3);
|
||||
assert!(matches!(mentions[0].1, MentionUri::File { .. }));
|
||||
assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
|
||||
assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
|
||||
|
||||
// Adjacent mentions without separator
|
||||
let text = "[@a](file:///a)[@b](file:///b)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 2);
|
||||
|
||||
// Regular markdown link (not a mention) should be ignored
|
||||
let text = "[regular link](https://example.com)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 0);
|
||||
|
||||
// Incomplete mention link patterns
|
||||
let text = "[@name] without url and [@name( malformed";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 0);
|
||||
|
||||
// Nested brackets in name portion
|
||||
let text = "[@name [with brackets]](file:///path/to/file)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
assert_eq!(mentions[0].0, 0..text.len());
|
||||
|
||||
// Deeply nested brackets
|
||||
let text = "[@outer [inner [deep]]](file:///path)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
|
||||
// Unbalanced brackets should fail gracefully
|
||||
let text = "[@unbalanced [bracket](file:///path)";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 0);
|
||||
|
||||
// Nested parentheses in URI (common in URLs with query params)
|
||||
let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
|
||||
let mentions = parse_mention_links(text, PathStyle::local());
|
||||
assert_eq!(mentions.len(), 1);
|
||||
if let MentionUri::Fetch { url } = &mentions[0].1 {
|
||||
assert!(url.as_str().contains("Rust_(programming_language)"));
|
||||
} else {
|
||||
panic!("Expected Fetch URI");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_at_mention_removal(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -118,6 +118,41 @@ impl MentionSet {
|
||||
self.mentions.insert(crease_id, (uri, task));
|
||||
}
|
||||
|
||||
/// Creates the appropriate confirmation task for a mention based on its URI type.
|
||||
/// This is used when pasting mention links to properly load their content.
|
||||
pub fn confirm_mention_for_uri(
|
||||
&mut self,
|
||||
mention_uri: MentionUri,
|
||||
supports_images: bool,
|
||||
http_client: Arc<HttpClientWithUrl>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Mention>> {
|
||||
match mention_uri {
|
||||
MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, http_client, cx),
|
||||
MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
|
||||
MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
|
||||
MentionUri::TextThread { .. } => {
|
||||
Task::ready(Err(anyhow!("Text thread mentions are no longer supported")))
|
||||
}
|
||||
MentionUri::File { abs_path } => {
|
||||
self.confirm_mention_for_file(abs_path, supports_images, cx)
|
||||
}
|
||||
MentionUri::Symbol {
|
||||
abs_path,
|
||||
line_range,
|
||||
..
|
||||
} => self.confirm_mention_for_symbol(abs_path, line_range, cx),
|
||||
MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
|
||||
MentionUri::Diagnostics {
|
||||
include_errors,
|
||||
include_warnings,
|
||||
} => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
|
||||
MentionUri::PastedImage | MentionUri::Selection { .. } => {
|
||||
Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_mention(&mut self, crease_id: &CreaseId) {
|
||||
self.mentions.remove(crease_id);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user