Fix clicking on file links in editor (#25117)

Closes #18641
Contributes: #13194

Release Notes:

- Open LSP documentation file links in Zed not the system opener
- Render completion documentation markdown consistently with
documentation markdown
This commit is contained in:
Conrad Irwin 2025-02-18 22:54:35 -07:00 committed by GitHub
parent ebbc6a9752
commit 1678e3cbf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 353 additions and 352 deletions

View file

@ -20,7 +20,6 @@ test-support = [
[dependencies]
anyhow.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
linkify.workspace = true
@ -34,7 +33,7 @@ util.workspace = true
assets.workspace = true
env_logger.workspace = true
gpui = { workspace = true, features = ["test-support"] }
languages.workspace = true
languages = { workspace = true, features = ["load-grammars"] }
node_runtime.workspace = true
settings = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }

View file

@ -15,84 +15,12 @@ const MARKDOWN_EXAMPLE: &str = r#"
## Headings
Headings are created by adding one or more `#` symbols before your heading text. The number of `#` you use will determine the size of the heading.
```rust
gpui::window::ViewContext
impl<'a, V> ViewContext<'a, V>
pub fn on_blur(&mut self, handle: &FocusHandle, listener: impl FnMut(&mut V, &mut iewContext<V>) + 'static) -> Subscription
where
// Bounds from impl:
V: 'static,
```
function a(b: T) {
## Emphasis
Emphasis can be added with italics or bold. *This text will be italic*. _This will also be italic_
## Lists
### Unordered Lists
Unordered lists use asterisks `*`, plus `+`, or minus `-` as list markers.
* Item 1
* Item 2
* Item 2a
* Item 2b
### Ordered Lists
Ordered lists use numbers followed by a period.
1. Item 1
2. Item 2
3. Item 3
1. Item 3a
2. Item 3b
## Links
Links are created using the format [http://zed.dev](https://zed.dev).
They can also be detected automatically, for example https://zed.dev/blog.
They may contain dollar signs:
[https://svelte.dev/docs/svelte/$state](https://svelte.dev/docs/svelte/$state)
https://svelte.dev/docs/svelte/$state
## Images
Images are like links, but with an exclamation mark `!` in front.
```markdown
![This is an image](/images/logo.png)
```
## Code
Inline `code` can be wrapped with backticks `` ` ``.
```markdown
Inline `code` has `back-ticks around` it.
```
Code blocks can be created by indenting lines by four spaces or with triple backticks ```.
```javascript
function test() {
console.log("notice the blank line before this function?");
}
```
## Blockquotes
Blockquotes are created with `>`.
> This is a blockquote.
## Horizontal Rules
Horizontal rules are created using three or more asterisks `***`, dashes `---`, or underscores `___`.
## Line breaks
This is a
\
line break!
---
Remember, markdown processors may have slight differences and extensions, so always refer to the specific documentation or guides relevant to your platform or editor for the best practices and additional features.
"#;
@ -161,7 +89,7 @@ pub fn main() {
};
MarkdownExample::new(
MARKDOWN_EXAMPLE.to_string(),
MARKDOWN_EXAMPLE.into(),
markdown_style,
language_registry,
window,
@ -179,14 +107,22 @@ struct MarkdownExample {
impl MarkdownExample {
pub fn new(
text: String,
text: SharedString,
style: MarkdownStyle,
language_registry: Arc<LanguageRegistry>,
window: &mut Window,
cx: &mut App,
) -> Self {
let markdown =
cx.new(|cx| Markdown::new(text, style, Some(language_registry), None, window, cx));
let markdown = cx.new(|cx| {
Markdown::new(
text,
style,
Some(language_registry),
Some("TypeScript".to_string()),
window,
cx,
)
});
Self { markdown }
}
}

View file

@ -1,7 +1,6 @@
pub mod parser;
use crate::parser::CodeBlockKind;
use futures::FutureExt;
use gpui::{
actions, point, quad, AnyElement, App, Bounds, ClipboardItem, CursorStyle, DispatchPhase,
Edges, Entity, FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla,
@ -12,7 +11,7 @@ use gpui::{
use language::{Language, LanguageRegistry, Rope};
use parser::{parse_links_only, parse_markdown, MarkdownEvent, MarkdownTag, MarkdownTagEnd};
use std::{iter, mem, ops::Range, rc::Rc, sync::Arc};
use std::{collections::HashMap, iter, mem, ops::Range, rc::Rc, sync::Arc};
use theme::SyntaxTheme;
use ui::{prelude::*, Tooltip};
use util::{ResultExt, TryFutureExt};
@ -49,7 +48,7 @@ impl Default for MarkdownStyle {
}
pub struct Markdown {
source: String,
source: SharedString,
selection: Selection,
pressed_link: Option<RenderedLink>,
autoscroll_request: Option<usize>,
@ -60,6 +59,7 @@ pub struct Markdown {
focus_handle: FocusHandle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
open_url: Option<Box<dyn Fn(SharedString, &mut Window, &mut App)>>,
options: Options,
}
@ -73,7 +73,7 @@ actions!(markdown, [Copy]);
impl Markdown {
pub fn new(
source: String,
source: SharedString,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
@ -97,13 +97,24 @@ impl Markdown {
parse_links_only: false,
copy_code_block_buttons: true,
},
open_url: None,
};
this.parse(window, cx);
this
}
pub fn open_url(
self,
open_url: impl Fn(SharedString, &mut Window, &mut App) + 'static,
) -> Self {
Self {
open_url: Some(Box::new(open_url)),
..self
}
}
pub fn new_text(
source: String,
source: SharedString,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
@ -127,6 +138,7 @@ impl Markdown {
parse_links_only: true,
copy_code_block_buttons: true,
},
open_url: None,
};
this.parse(window, cx);
this
@ -137,11 +149,11 @@ impl Markdown {
}
pub fn append(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
self.source.push_str(text);
self.source = SharedString::new(self.source.to_string() + text);
self.parse(window, cx);
}
pub fn reset(&mut self, source: String, window: &mut Window, cx: &mut Context<Self>) {
pub fn reset(&mut self, source: SharedString, window: &mut Window, cx: &mut Context<Self>) {
if source == self.source() {
return;
}
@ -176,17 +188,38 @@ impl Markdown {
return;
}
let text = self.source.clone();
let source = self.source.clone();
let parse_text_only = self.options.parse_links_only;
let language_registry = self.language_registry.clone();
let fallback = self.fallback_code_block_language.clone();
let parsed = cx.background_spawn(async move {
let text = SharedString::from(text);
let events = match parse_text_only {
true => Arc::from(parse_links_only(text.as_ref())),
false => Arc::from(parse_markdown(text.as_ref())),
};
if parse_text_only {
return anyhow::Ok(ParsedMarkdown {
events: Arc::from(parse_links_only(source.as_ref())),
source,
languages: HashMap::default(),
});
}
let (events, language_names) = parse_markdown(&source);
let mut languages = HashMap::with_capacity(language_names.len());
for name in language_names {
if let Some(registry) = language_registry.as_ref() {
let language = if !name.is_empty() {
registry.language_for_name(&name)
} else if let Some(fallback) = &fallback {
registry.language_for_name(fallback)
} else {
continue;
};
if let Ok(language) = language.await {
languages.insert(name, language);
}
}
}
anyhow::Ok(ParsedMarkdown {
source: text,
events,
source,
events: Arc::from(events),
languages,
})
});
@ -217,12 +250,7 @@ impl Markdown {
impl Render for Markdown {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
MarkdownElement::new(
cx.entity().clone(),
self.style.clone(),
self.language_registry.clone(),
self.fallback_code_block_language.clone(),
)
MarkdownElement::new(cx.entity().clone(), self.style.clone())
}
}
@ -270,6 +298,7 @@ impl Selection {
pub struct ParsedMarkdown {
source: SharedString,
events: Arc<[(Range<usize>, MarkdownEvent)]>,
languages: HashMap<SharedString, Arc<Language>>,
}
impl ParsedMarkdown {
@ -285,61 +314,11 @@ impl ParsedMarkdown {
pub struct MarkdownElement {
markdown: Entity<Markdown>,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
}
impl MarkdownElement {
fn new(
markdown: Entity<Markdown>,
style: MarkdownStyle,
language_registry: Option<Arc<LanguageRegistry>>,
fallback_code_block_language: Option<String>,
) -> Self {
Self {
markdown,
style,
language_registry,
fallback_code_block_language,
}
}
fn load_language(
&self,
name: &str,
window: &mut Window,
cx: &mut App,
) -> Option<Arc<Language>> {
let language_test = self.language_registry.as_ref()?.language_for_name(name);
let language_name = match language_test.now_or_never() {
Some(Ok(_)) => String::from(name),
Some(Err(_)) if !name.is_empty() && self.fallback_code_block_language.is_some() => {
self.fallback_code_block_language.clone().unwrap()
}
_ => String::new(),
};
let language = self
.language_registry
.as_ref()?
.language_for_name(language_name.as_str())
.map(|language| language.ok())
.shared();
match language.clone().now_or_never() {
Some(language) => language,
None => {
let markdown = self.markdown.downgrade();
window
.spawn(cx, |mut cx| async move {
language.await;
markdown.update(&mut cx, |_, cx| cx.notify())
})
.detach_and_log_err(cx);
None
}
}
fn new(markdown: Entity<Markdown>, style: MarkdownStyle) -> Self {
Self { markdown, style }
}
fn paint_selection(
@ -452,7 +431,7 @@ impl MarkdownElement {
pending: true,
};
window.focus(&markdown.focus_handle);
window.prevent_default()
window.prevent_default();
}
cx.notify();
@ -492,11 +471,15 @@ impl MarkdownElement {
});
self.on_mouse_event(window, cx, {
let rendered_text = rendered_text.clone();
move |markdown, event: &MouseUpEvent, phase, _, cx| {
move |markdown, event: &MouseUpEvent, phase, window, cx| {
if phase.bubble() {
if let Some(pressed_link) = markdown.pressed_link.take() {
if Some(&pressed_link) == rendered_text.link_for_position(event.position) {
cx.open_url(&pressed_link.destination_url);
if let Some(open_url) = markdown.open_url.as_mut() {
open_url(pressed_link.destination_url, window, cx);
} else {
cx.open_url(&pressed_link.destination_url);
}
}
}
} else if markdown.selection.pending {
@ -617,7 +600,7 @@ impl Element for MarkdownElement {
}
MarkdownTag::CodeBlock(kind) => {
let language = if let CodeBlockKind::Fenced(language) = kind {
self.load_language(language.as_ref(), window, cx)
parsed_markdown.languages.get(language).cloned()
} else {
None
};

View file

@ -2,15 +2,16 @@ use gpui::SharedString;
use linkify::LinkFinder;
pub use pulldown_cmark::TagEnd as MarkdownTagEnd;
use pulldown_cmark::{Alignment, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser};
use std::ops::Range;
use std::{collections::HashSet, ops::Range};
pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
pub fn parse_markdown(text: &str) -> (Vec<(Range<usize>, MarkdownEvent)>, HashSet<SharedString>) {
let mut options = Options::all();
options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
options.remove(pulldown_cmark::Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
options.remove(pulldown_cmark::Options::ENABLE_MATH);
let mut events = Vec::new();
let mut languages = HashSet::new();
let mut within_link = false;
let mut within_metadata = false;
for (pulldown_event, mut range) in Parser::new_ext(text, options).into_offset_iter() {
@ -27,6 +28,11 @@ pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
match tag {
pulldown_cmark::Tag::Link { .. } => within_link = true,
pulldown_cmark::Tag::MetadataBlock { .. } => within_metadata = true,
pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(
ref language,
)) => {
languages.insert(SharedString::from(language.to_string()));
}
_ => {}
}
events.push((range, MarkdownEvent::Start(tag.into())))
@ -102,7 +108,7 @@ pub fn parse_markdown(text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {
pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {}
}
}
events
(events, languages)
}
pub fn parse_links_only(mut text: &str) -> Vec<(Range<usize>, MarkdownEvent)> {