diff --git a/src/core/engine/panels.rs b/src/core/engine/panels.rs index c3da0a0f..7fca49ae 100644 --- a/src/core/engine/panels.rs +++ b/src/core/engine/panels.rs @@ -1352,10 +1352,17 @@ impl Engine { /// Extract clickable links from rendered markdown. /// - /// Pairs each `Link` span (the label text) with the following `LinkUrl` span - /// (the URL) on the same line. The returned click region covers the label, - /// while the URL is used for dispatch. Command URIs displayed as `:Name?args` - /// are restored to `command:Name?args`. + /// Two sources of click regions are handled: + /// + /// 1. **Markdown links** — each `Link` span (the label text) is paired with + /// the following `LinkUrl` span on the same line. The click region covers + /// the label; the URL drives dispatch. Command URIs displayed as + /// `:Name?args` are restored to `command:Name?args`. + /// + /// 2. **Bare URLs** — standalone `LinkUrl` spans (emitted by `render_markdown` + /// for plain `http://` / `https://` text) become their own click regions. + /// The span text is the URL itself, so no reconstruction is needed beyond + /// the same `:` → `command:` prefix check used for markdown link URLs. pub(crate) fn extract_hover_links( rendered: &crate::core::markdown::MdRendered, ) -> Vec<(usize, usize, usize, String)> { @@ -1365,11 +1372,11 @@ impl Engine { let Some(line) = rendered.lines.get(line_idx) else { continue; }; - // Find each Link span and pair it with the next LinkUrl on the same line. + // Walk every span on this line. let mut span_iter = line_spans.iter().peekable(); while let Some(span) = span_iter.next() { if span.style == MdStyle::Link { - // Look for the following LinkUrl span to get the URL. + // Paired markdown link: look for the following LinkUrl span. let url = span_iter .peek() .filter(|next| next.style == MdStyle::LinkUrl) @@ -1392,6 +1399,19 @@ impl Engine { links.push((line_idx, span.start_byte, span.end_byte, url)); } } + } else if span.style == MdStyle::LinkUrl && span.end_byte <= line.len() { + // Standalone LinkUrl span (bare URL or the URL display of a + // markdown link). Make the span itself clickable. + let url_text = &line[span.start_byte..span.end_byte]; + // Restore command: prefix if displayed as ":Name?args". + let url = if url_text.starts_with(':') { + format!("command{}", url_text) + } else { + url_text.to_string() + }; + if is_safe_url(&url) { + links.push((line_idx, span.start_byte, span.end_byte, url)); + } } } } diff --git a/src/core/engine/tests.rs b/src/core/engine/tests.rs index 81d2b9e4..aecb00a7 100644 --- a/src/core/engine/tests.rs +++ b/src/core/engine/tests.rs @@ -26395,3 +26395,107 @@ fn test_dialog_click_button() { DialogClickResult::InsideDialog ); } + +// ─── Bare-URL hover link tests ──────────────────────────────────────────────── + +#[test] +fn test_bare_url_detected_as_link_url_span() { + // A plain paragraph containing a bare https URL should produce a LinkUrl span + // whose byte range extracts to the exact URL text. + let r = crate::core::markdown::render_markdown("See https://example.com for details."); + let url_texts: Vec = r + .spans + .iter() + .enumerate() + .flat_map(|(li, line_spans)| { + line_spans + .iter() + .filter(|s| s.style == crate::core::markdown::MdStyle::LinkUrl) + .map(move |s| (li, s.start_byte, s.end_byte)) + .collect::>() + }) + .filter_map(|(li, start, end)| r.lines.get(li).map(|line| line[start..end].to_string())) + .collect(); + assert!( + url_texts.contains(&"https://example.com".to_string()), + "expected LinkUrl span for bare URL, got: {:?}", + url_texts + ); +} + +#[test] +fn test_bare_url_trailing_punctuation_stripped() { + // Trailing periods, commas, and closing parentheses must be stripped. + let r = crate::core::markdown::render_markdown( + "Visit https://example.com. Also https://other.org, and (https://third.io).", + ); + // Collect (line_idx, start, end) for all LinkUrl spans, then extract text. + let url_texts: Vec = r + .spans + .iter() + .enumerate() + .flat_map(|(li, line_spans)| { + line_spans + .iter() + .filter(|s| s.style == crate::core::markdown::MdStyle::LinkUrl) + .map(move |s| (li, s.start_byte, s.end_byte)) + .collect::>() + }) + .filter_map(|(li, start, end)| r.lines.get(li).map(|line| line[start..end].to_string())) + .collect(); + assert!( + url_texts.contains(&"https://example.com".to_string()), + "trailing period not stripped; got: {:?}", + url_texts + ); + assert!( + url_texts.contains(&"https://other.org".to_string()), + "trailing comma not stripped; got: {:?}", + url_texts + ); + assert!( + url_texts.contains(&"https://third.io".to_string()), + "trailing paren+period not stripped; got: {:?}", + url_texts + ); +} + +#[test] +fn test_bare_url_registers_click_region() { + // A hover popup with a bare URL should produce a clickable link entry. + let mut e = engine_with_text("hello\n"); + e.show_panel_hover("ext_panel", "i", 0, "See https://rust-lang.org for more."); + let ph = e.panel_hover.as_ref().unwrap(); + let found = ph + .links + .iter() + .any(|(_, _, _, url)| url == "https://rust-lang.org"); + assert!( + found, + "expected click region for bare URL, links: {:?}", + ph.links + ); +} + +#[test] +fn test_existing_markdown_link_behavior_unchanged() { + // Standard [label](url) markdown links must still produce a click region on + // the label span AND the URL display span (now both are clickable). + let mut e = engine_with_text("hello\n"); + e.show_panel_hover("ext_panel", "i", 0, "[click here](https://example.com)"); + let ph = e.panel_hover.as_ref().unwrap(); + // At minimum the label-based click region must exist. + let label_click = ph + .links + .iter() + .any(|(_, _, _, url)| url == "https://example.com"); + assert!( + label_click, + "expected click region for markdown link label, links: {:?}", + ph.links + ); + // All registered URLs must pass is_safe_url. + for link in &ph.links { + assert!(is_safe_url(&link.3), "unsafe URL in links: {}", link.3); + } +} diff --git a/src/core/markdown.rs b/src/core/markdown.rs index dbe95959..665a38dd 100644 --- a/src/core/markdown.rs +++ b/src/core/markdown.rs @@ -57,6 +57,59 @@ pub struct MdRendered { // ─── Rendering ─────────────────────────────────────────────────────────────── +/// Scan a plain-text chunk for bare `http://` / `https://` URLs and push +/// `MdStyle::LinkUrl` spans into `spans`. +/// +/// * `chunk_bytes` — the raw bytes of the text chunk (UTF-8). +/// * `line_offset` — byte position where this chunk starts inside `cur_line`. +/// * `spans` — destination span list for the current line. +/// +/// No regex crate is used: we scan byte-by-byte for the scheme prefix, walk +/// forward to the next ASCII whitespace, then strip trailing punctuation +/// (`.`, `,`, `)`). +fn scan_bare_urls(chunk_bytes: &[u8], line_offset: usize, spans: &mut Vec) { + let len = chunk_bytes.len(); + let mut i = 0usize; + while i < len { + // Detect scheme prefix. + let scheme_len = if chunk_bytes[i..].starts_with(b"https://") { + 8 + } else if chunk_bytes[i..].starts_with(b"http://") { + 7 + } else { + i += 1; + continue; + }; + + let url_start = i; + + // Walk to next ASCII whitespace. + let mut j = i + scheme_len; + while j < len && !chunk_bytes[j].is_ascii_whitespace() { + j += 1; + } + + // Strip trailing punctuation characters. + while j > url_start + scheme_len { + match chunk_bytes[j - 1] { + b'.' | b',' | b')' => j -= 1, + _ => break, + } + } + + if j > url_start + scheme_len { + spans.push(MdSpan { + start_byte: line_offset + url_start, + end_byte: line_offset + j, + style: MdStyle::LinkUrl, + }); + } + + // Advance past the URL (or at least by 1 to avoid an infinite loop). + i = j.max(url_start + 1); + } +} + /// Convert a markdown string into styled plain text. pub fn render_markdown(input: &str) -> MdRendered { let mut lines: Vec = Vec::new(); @@ -431,6 +484,8 @@ pub fn render_markdown(input: &str) -> MdRendered { } else if in_link { MdStyle::Link } else { + // Plain text: scan for bare http/https URLs. + scan_bare_urls(chunk.as_bytes(), start, &mut cur_spans); at_line_start = false; continue; };