From 800f40524b8f74a1b6a9a4d6b85e8c0ce750a408 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 4 Apr 2025 11:39:48 +0200 Subject: [PATCH] agent: Differentiate @mentions from markdown links (#28073) This ensures that we display @mentions and normal markdown links differently: Screenshot 2025-04-04 at 11 07 51 Release Notes: - N/A --- crates/agent/src/active_thread.rs | 12 +++++ crates/agent/src/context_picker.rs | 54 ++++++++++++------- .../src/context_picker/completion_provider.rs | 36 ++++++------- crates/markdown/src/markdown.rs | 14 ++++- 4 files changed, 78 insertions(+), 38 deletions(-) diff --git a/crates/agent/src/active_thread.rs b/crates/agent/src/active_thread.rs index 933f7bd0b7d90db6c828aeb857672afebdb932ad..6cc67e0141277d667dffbcc0dd543fe6f06e2a1f 100644 --- a/crates/agent/src/active_thread.rs +++ b/crates/agent/src/active_thread.rs @@ -245,6 +245,17 @@ fn render_markdown( }), ..Default::default() }, + link_callback: Some(Rc::new(move |url, cx| { + if MentionLink::is_valid(url) { + let colors = cx.theme().colors(); + Some(TextStyleRefinement { + background_color: Some(colors.element_background), + ..Default::default() + }) + } else { + None + } + })), ..Default::default() }; @@ -320,6 +331,7 @@ fn open_markdown_link( }); } }), + Some(MentionLink::Fetch(url)) => cx.open_url(&url), None => cx.open_url(&text), } } diff --git a/crates/agent/src/context_picker.rs b/crates/agent/src/context_picker.rs index a1588f3ec6257e955491acda8a81cb08167c267f..e574226370e59617b91a80e95ecaec679d6128f7 100644 --- a/crates/agent/src/context_picker.rs +++ b/crates/agent/src/context_picker.rs @@ -609,24 +609,45 @@ fn fold_toggle( pub enum MentionLink { File(ProjectPath, Entry), Symbol(ProjectPath, String), + Fetch(String), Thread(ThreadId), } impl MentionLink { + const FILE: &str = "@file"; + const SYMBOL: &str = "@symbol"; + const THREAD: &str = "@thread"; + const FETCH: &str = "@fetch"; + + const SEPARATOR: &str = ":"; + + pub fn is_valid(url: &str) -> bool { + url.starts_with(Self::FILE) + || url.starts_with(Self::SYMBOL) + || url.starts_with(Self::FETCH) + || url.starts_with(Self::THREAD) + } + pub fn for_file(file_name: &str, full_path: &str) -> String { - format!("[@{}](file:{})", file_name, full_path) + format!("[@{}]({}:{})", file_name, Self::FILE, full_path) } pub fn for_symbol(symbol_name: &str, full_path: &str) -> String { - format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name) + format!( + "[@{}]({}:{}:{})", + symbol_name, + Self::SYMBOL, + full_path, + symbol_name + ) } pub fn for_fetch(url: &str) -> String { - format!("[@{}]({})", url, url) + format!("[@{}]({}:{})", url, Self::FETCH, url) } pub fn for_thread(thread: &ThreadContextEntry) -> String { - format!("[@{}](thread:{})", thread.summary, thread.id) + format!("[@{}]({}:{})", thread.summary, Self::THREAD, thread.id) } pub fn try_parse(link: &str, workspace: &Entity, cx: &App) -> Option { @@ -649,17 +670,10 @@ impl MentionLink { }) } - let (prefix, link, target) = { - let mut parts = link.splitn(3, ':'); - let prefix = parts.next(); - let link = parts.next(); - let target = parts.next(); - (prefix, link, target) - }; - - match (prefix, link, target) { - (Some("file"), Some(path), _) => { - let project_path = extract_project_path_from_link(path, workspace, cx)?; + let (prefix, argument) = link.split_once(Self::SEPARATOR)?; + match prefix { + Self::FILE => { + let project_path = extract_project_path_from_link(argument, workspace, cx)?; let entry = workspace .read(cx) .project() @@ -667,14 +681,16 @@ impl MentionLink { .entry_for_path(&project_path, cx)?; Some(MentionLink::File(project_path, entry)) } - (Some("symbol"), Some(path), Some(symbol_name)) => { + Self::SYMBOL => { + let (path, symbol) = argument.split_once(Self::SEPARATOR)?; let project_path = extract_project_path_from_link(path, workspace, cx)?; - Some(MentionLink::Symbol(project_path, symbol_name.to_string())) + Some(MentionLink::Symbol(project_path, symbol.to_string())) } - (Some("thread"), Some(thread_id), _) => { - let thread_id = ThreadId::from(thread_id); + Self::THREAD => { + let thread_id = ThreadId::from(argument); Some(MentionLink::Thread(thread_id)) } + Self::FETCH => Some(MentionLink::Fetch(argument.to_string())), _ => None, } } diff --git a/crates/agent/src/context_picker/completion_provider.rs b/crates/agent/src/context_picker/completion_provider.rs index c0af143d7555f23d243f726ce03dd72ff033b9af..d5f02a7d846dd224657d6d722e5a71bd4bf5a79f 100644 --- a/crates/agent/src/context_picker/completion_provider.rs +++ b/crates/agent/src/context_picker/completion_provider.rs @@ -932,22 +932,22 @@ mod tests { }); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt)",); + assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt)",); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 36)] + vec![Point::new(0, 6)..Point::new(0, 37)] ); }); cx.simulate_input(" "); editor.update(&mut cx, |editor, cx| { - assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt) ",); + assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ",); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 36)] + vec![Point::new(0, 6)..Point::new(0, 37)] ); }); @@ -956,12 +956,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](file:dir/a/one.txt) Ipsum ", + "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ", ); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 36)] + vec![Point::new(0, 6)..Point::new(0, 37)] ); }); @@ -970,12 +970,12 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](file:dir/a/one.txt) Ipsum @file ", + "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ", ); assert!(editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), - vec![Point::new(0, 6)..Point::new(0, 36)] + vec![Point::new(0, 6)..Point::new(0, 37)] ); }); @@ -988,14 +988,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@editor](file:dir/editor)" + "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)" ); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 36), - Point::new(0, 43)..Point::new(0, 69) + Point::new(0, 6)..Point::new(0, 37), + Point::new(0, 44)..Point::new(0, 71) ] ); }); @@ -1005,14 +1005,14 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@editor](file:dir/editor)\n@" + "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n@" ); assert!(editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 36), - Point::new(0, 43)..Point::new(0, 69) + Point::new(0, 6)..Point::new(0, 37), + Point::new(0, 44)..Point::new(0, 71) ] ); }); @@ -1026,15 +1026,15 @@ mod tests { editor.update(&mut cx, |editor, cx| { assert_eq!( editor.text(cx), - "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@editor](file:dir/editor)\n[@seven.txt](file:dir/b/seven.txt)" + "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@editor](@file:dir/editor)\n[@seven.txt](@file:dir/b/seven.txt)" ); assert!(!editor.has_visible_completions_menu()); assert_eq!( crease_ranges(editor, cx), vec![ - Point::new(0, 6)..Point::new(0, 36), - Point::new(0, 43)..Point::new(0, 69), - Point::new(1, 0)..Point::new(1, 34) + Point::new(0, 6)..Point::new(0, 37), + Point::new(0, 44)..Point::new(0, 71), + Point::new(1, 0)..Point::new(1, 35) ] ); }); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 122fe3fc169a8f3c3400d06a8c1124f2a689fbda..c8da8dcd64d8857cca98270af441452da02f49c4 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -24,6 +24,10 @@ use util::{ResultExt, TryFutureExt}; use crate::parser::CodeBlockKind; +/// A callback function that can be used to customize the style of links based on the destination URL. +/// If the callback returns `None`, the default link style will be used. +type LinkStyleCallback = Rc Option>; + #[derive(Clone)] pub struct MarkdownStyle { pub base_text_style: TextStyle, @@ -32,6 +36,7 @@ pub struct MarkdownStyle { pub inline_code: TextStyleRefinement, pub block_quote: TextStyleRefinement, pub link: TextStyleRefinement, + pub link_callback: Option, pub rule_color: Hsla, pub block_quote_border_color: Hsla, pub syntax: Arc, @@ -49,6 +54,7 @@ impl Default for MarkdownStyle { inline_code: Default::default(), block_quote: Default::default(), link: Default::default(), + link_callback: None, rule_color: Default::default(), block_quote_border_color: Default::default(), syntax: Arc::new(SyntaxTheme::default()), @@ -679,7 +685,13 @@ impl Element for MarkdownElement { MarkdownTag::Link { dest_url, .. } => { if builder.code_block_stack.is_empty() { builder.push_link(dest_url.clone(), range.clone()); - builder.push_text_style(self.style.link.clone()) + let style = self + .style + .link_callback + .as_ref() + .and_then(|callback| callback(dest_url, cx)) + .unwrap_or_else(|| self.style.link.clone()); + builder.push_text_style(style) } } MarkdownTag::MetadataBlock(_) => {}