Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions src/core/engine/panels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)> {
Expand All @@ -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)
Expand All @@ -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));
}
}
}
}
Expand Down
104 changes: 104 additions & 0 deletions src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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::<Vec<_>>()
})
.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<String> = 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::<Vec<_>>()
})
.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);
}
}
55 changes: 55 additions & 0 deletions src/core/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MdSpan>) {
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<String> = Vec::new();
Expand Down Expand Up @@ -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;
};
Expand Down