Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6d086a0
docs(specs): add GH13725 spec for markdown viewer <a href>/<a id> anc…
fbartho Jul 14, 2026
d2122a0
Retarget anchor spec at the existing fragment-scroll path (GH13725)
fbartho Jul 17, 2026
645e363
Add cross-document fragment navigation as phase 3
fbartho Jul 17, 2026
cc40c91
Address review findings on anchor spec (GH13725)
fbartho Jul 18, 2026
9373cef
Merge branch 'fb/13725-markdown-anchor-links-spec' into fb/13725-mark…
fbartho Jul 19, 2026
8529413
feat(markdown): parse raw HTML <a href> anchors as hyperlinks (#13725)
fbartho Jul 19, 2026
622faf1
feat(notebooks): resolve #fragment links via GitHub-style heading slu…
fbartho Jul 19, 2026
f371fd1
feat(notebooks): resolve cross-document #fragment links (#13725)
fbartho Jul 19, 2026
f6d7b8b
docs(spec): move cross-document fragments into delivered scope (GH13725)
fbartho Jul 19, 2026
1709638
feat(notebooks): dedup self-referential links to the open tab (#13725)
fbartho Jul 19, 2026
c3706a2
docs(spec): specify self-referential relative links (GH13725)
fbartho Jul 19, 2026
0cfff3e
test(notebooks): guard multi-segment paths in ccTLD matrix (#13725)
fbartho Jul 19, 2026
3532e1b
Reconcile two stale anchor view tests with spec'd behavior (#13725)
fbartho Jul 19, 2026
589014b
fix(notebooks): resolve relative links in session-less viewer (#13725)
fbartho Jul 19, 2026
0836fc0
feat(notebooks): resolve <a id>/<a name> anchor targets (#13725)
fbartho Jul 19, 2026
c3bbbd4
docs(spec): move <a id>/<a name> anchor targets into delivered scope …
fbartho Jul 19, 2026
22ccd68
docs(spec): document visible anchor-tag rendering, defer hiding to #1…
fbartho Jul 19, 2026
ef84320
Merge remote-tracking branch 'origin/master' into fb/13725-markdown-a…
fbartho Jul 20, 2026
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
2 changes: 1 addition & 1 deletion app/src/ai/ai_document_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ impl AIDocumentView {
) -> Self {
let window_id = ctx.window_id();

let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::active(window_id), ctx));

// Subscribe to document model updates so we can react to agent changes
ctx.subscribe_to_model(
Expand Down
2 changes: 1 addition & 1 deletion app/src/code/editor/comment_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@ where
let parent_view_id = ctx.view_id();

let model = ctx.add_model(|ctx| NotebooksEditorModel::new(rich_text_styles, window_id, ctx));
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::active(window_id), ctx));

let parent_view_name = ctx.view_name(window_id, parent_view_id).unwrap_or_default();
let parent_position_id = format!("{}_{}", parent_view_name, parent_view_id);
Expand Down
2 changes: 1 addition & 1 deletion app/src/code/editor/comment_editor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ fn initialize_editor(

let (window, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
let window_id = ctx.window_id();
let _links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
let _links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::active(window_id), ctx));
let editor = match mode {
CommentEditorMode::Editable => create_editable_comment_markdown_editor(None, ctx),
CommentEditorMode::Readonly => create_readonly_comment_markdown_editor(
Expand Down
171 changes: 158 additions & 13 deletions app/src/notebooks/editor/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use markdown_parser::FormattedText;
use mermaid_to_svg::MermaidTheme;
use num_traits::SaturatingSub;
use regex::Regex;
use string_offset::CharOffset;
use string_offset::{CharCounter, CharOffset};
use url::Url;
use vec1::{Vec1, vec1};
use warp_core::r#async::debounce;
Expand Down Expand Up @@ -86,6 +86,17 @@ lazy_static! {
Regex::new(r"^\[ ?\] $").expect("Markdown shortcut regex should be valid");
static ref COMPLETE_TASKLIST_INLINE_REGEX: Regex =
Regex::new(r"^\[[xX]\] $").expect("Markdown shortcut regex should be valid");

/// Matches a raw HTML `<a id="…">`/`<a name="…">` anchor-target tag (single or double
/// quotes), capturing the id/name value. This is a plain text scan over the rendered buffer,
/// not a parser token: the phase-1 inline parser only recognizes `<a href>` as a delimiter
/// pair (see `markdown_parser::parse_inline_token_html_anchor_start`) — a bare `<a id>`/`<a
/// name>` tag with no `href` never matches that grammar and falls through to literal text,
/// so its raw markup (including the id) survives verbatim in the buffer. Scanning for it here
/// avoids adding any new zero-width content-model construct.
static ref HTML_ANCHOR_TARGET_TAG: Regex = Regex::new(
r#"(?i)<a\s+(?:id|name)\s*=\s*(?:"([^"]*)"|'([^']*)')"#
).expect("Anchor-tag regex should be valid");
}

#[cfg(test)]
Expand All @@ -108,6 +119,13 @@ pub struct NotebooksEditorModel {
/// Context used to generate clickable file path links for notebooks.
file_link_resolution_context: Option<FileLinkResolutionContext>,
default_mermaid_display_mode: MarkdownDisplayMode,
/// A `#fragment` anchor to scroll to once the document's layout is first built. Set when a
/// cross-document link (`other-file.md#section`) opens this notebook in a new tab: the target
/// offset does not exist until the buffer parses and lays out, and there is no on-load
/// callback to hang the scroll on, so we stash the fragment and drain it on the first
/// `LayoutUpdated`. Draining reuses the same `scroll_to_matching_header` slug resolver as the
/// same-document jump; a miss is a silent no-op, consistent with same-document miss semantics.
pending_anchor: Option<String>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -149,6 +167,79 @@ fn render_mermaid_clipboard_html(source: &str) -> Option<String> {
Some(mermaid_image_html(&svg))
}

/// Normalize heading text (or a `#fragment` link target) into a GitHub-compatible anchor slug.
///
/// This mirrors GitHub's `gfm-auto-identifiers` rule set, which is deliberately **not**
/// ASCII-only: it lowercases (Unicode-aware), drops punctuation and symbols, converts whitespace
/// runs to single hyphens, and **preserves every other Unicode letter/digit/mark** (accented
/// Latin, CJK, Cyrillic, …). So `"Café Société"` slugs to `café-société` and `"日本語"` is
/// unchanged — an ASCII-only `[a-z0-9 -]` filter would silently break every non-English anchor.
///
/// Applying the same function to both the heading text and the incoming fragment makes a
/// hyphenated `#target-section` resolve against a spaced heading "Target Section".
fn heading_slug(text: &str) -> String {
let mut slug = String::with_capacity(text.len());
let mut pending_hyphen = false;
for ch in text.chars() {
if ch.is_whitespace() {
// Collapse runs of whitespace; only emit the hyphen once a kept character follows.
pending_hyphen = !slug.is_empty();
} else if ch == '-' || ch.is_alphanumeric() {
// Preserve existing hyphens and any Unicode alphanumeric character.
if pending_hyphen {
slug.push('-');
pending_hyphen = false;
}
slug.extend(ch.to_lowercase());
}
// Everything else (ASCII punctuation, symbols) is dropped.
}
slug
}

/// Find the first `<a id="…">`/`<a name="…">` anchor tag in `text` whose value exactly matches
/// `target` (as authored — no slug normalization, no `user-content-` rewriting), returning the
/// tag's own `CharOffset` range. Anchor ids are compared to the fragment via [`urlencoding`]
/// alone (already applied by the caller before this runs), not [`heading_slug`]: unlike a
/// heading's derived slug, an explicit `<a id>` is an author-chosen literal string.
///
/// This walks the buffer's plain text with a regex rather than any parsed anchor index, mirroring
/// how heading matching re-reads text from the buffer on every click (no cache, nothing to
/// invalidate). Byte offsets from the regex match are converted to `CharOffset` with
/// [`CharCounter`], the same bridge used for search-match highlighting
/// (`global_search::view`) — necessary because a `CharOffset` counts `char`s, not bytes, and a
/// naive byte offset would misplace the scroll target on any non-ASCII text preceding the tag.
fn find_matching_anchor_tag(text: &str, target: &str) -> Option<Range<CharOffset>> {
if target.is_empty() {
return None;
}
let total_chars = CharOffset::from(text.chars().count());
let mut char_counter = CharCounter::new(text);
for capture in HTML_ANCHOR_TARGET_TAG.captures_iter(text) {
let whole_match = capture.get(0).expect("capture group 0 always matches");
let value = capture
.get(1)
.or_else(|| capture.get(2))
.expect("id/name value must be captured by one of the quote alternatives")
.as_str();
if value != target {
continue;
}
let start = char_counter.char_offset(whole_match.start())?;
// `char_offset` returns `None` when the byte offset is exactly the length of the string
// (there is no character starting there) — the tag-is-the-last-thing-in-the-document
// case. Fall back to the total character count, mirroring the same edge case handled in
// `GlobalSearchView::highlight_indices_from_submatches`.
let end = if whole_match.end() == text.len() {
total_chars
} else {
char_counter.char_offset(whole_match.end())?
};
return Some(start..end);
}
None
}

impl NotebooksEditorModel {
fn editable_markdown_mermaid_enabled() -> bool {
FeatureFlag::MarkdownMermaid.is_enabled()
Expand Down Expand Up @@ -228,6 +319,7 @@ impl NotebooksEditorModel {
resize_tx,
file_link_resolution_context: None,
default_mermaid_display_mode: MarkdownDisplayMode::Raw,
pending_anchor: None,
}
}

Expand Down Expand Up @@ -351,6 +443,9 @@ impl NotebooksEditorModel {
if self.sync_mermaid_render_offsets(ctx) {
self.rebuild_layout(ctx);
}
// Now that the document is laid out, heading offsets exist, so a queued
// cross-document anchor can resolve and scroll. Drained once.
self.drain_pending_anchor(ctx);
}
_ => (),
}
Expand Down Expand Up @@ -1332,6 +1427,29 @@ impl NotebooksEditorModel {
self.content.as_ref(app).link_url_at_offset(offset)
}

/// Queue a `#fragment` anchor to be scrolled to once this document's layout is first built.
///
/// Used for cross-document navigation (`other-file.md#section`) when the notebook is opened in
/// a *new* tab: the target offset does not exist until the buffer parses and lays out. The
/// pending anchor is drained on the first `LayoutUpdated` (see `handle_render_model_event`).
/// If the tab is already open, callers should use `scroll_to_matching_header` directly instead
/// — the buffer is already laid out, so no deferral is needed.
pub fn set_pending_anchor(&mut self, anchor: String) {
self.pending_anchor = Some(anchor);
}

/// Drain a queued cross-document anchor, scrolling to it if it matches a heading. Called once
/// the document's layout is ready. A miss (no matching heading) is a silent no-op, matching
/// the same-document miss semantics. Returns whether an anchor was drained *and* matched a
/// heading (i.e. a scroll was requested) — used in tests; callers on the layout path ignore it.
fn drain_pending_anchor(&mut self, ctx: &mut ModelContext<Self>) -> bool {
let Some(anchor) = self.pending_anchor.take() else {
return false;
};
// `find_matching_header` expects a leading `#`; the stored anchor is the bare fragment.
self.scroll_to_matching_header(&format!("#{anchor}"), ctx)
}

pub fn scroll_to_matching_header(
&mut self,
fragment: &str,
Expand All @@ -1348,35 +1466,62 @@ impl NotebooksEditorModel {
true
}

/// Resolve a `#fragment` against the document's addressable targets: explicit `<a id>`/`<a
/// name>` anchor tags and implicit heading slugs. Both kinds share one namespace — when a
/// fragment matches more than one candidate (an anchor and a heading colliding, or two of the
/// same kind), **first occurrence in document order wins**, mirroring GitHub's single shared
/// id space. This is a live walk with no cached index: anchors and headings are both read
/// straight out of the current buffer on every call, exactly like the pre-existing
/// heading-only walk this extends.
fn find_matching_header(&self, fragment: &str, ctx: &AppContext) -> Option<Range<CharOffset>> {
let target = fragment.strip_prefix('#')?;
if target.is_empty() {
let raw_target = fragment.strip_prefix('#')?;
if raw_target.is_empty() {
return None;
}
let target = urlencoding::decode(target).ok()?;
let target = target.trim().to_lowercase();
if target.is_empty() {
let raw_target = urlencoding::decode(raw_target).ok()?;
if raw_target.trim().is_empty() {
return None;
}
// Anchor ids match verbatim (as authored, no rewriting); headings match through the
// slug normalizer. Precompute both comparison keys once.
let anchor_target = raw_target.trim();
let heading_target = heading_slug(&raw_target);

let content = self.content.as_ref(ctx);
for outline in content.outline_blocks() {

let heading_match = content.outline_blocks().find_map(|outline| {
if !matches!(
&outline.block_type,
BlockType::Text(BufferBlockStyle::Header { .. })
) {
continue;
return None;
}
if heading_target.is_empty() {
return None;
}

let heading = content
.text_in_range(outline.start + 1..outline.end)
.into_string();
if heading.trim().to_lowercase() == target {
return Some(outline.start..outline.end);
(heading_slug(&heading) == heading_target).then_some(outline.start..outline.end)
});

let anchor_match = find_matching_anchor_tag(&content.text().into_string(), anchor_target);

// Merge both candidate kinds and take whichever starts earlier in the document — the
// shared-namespace, first-occurrence-wins rule (invariant: explicit anchors and headings
// compete on position, not on kind).
match (anchor_match, heading_match) {
(Some(anchor_range), Some(heading_range)) => {
Some(if anchor_range.start <= heading_range.start {
anchor_range
} else {
heading_range
})
}
(Some(anchor_range), None) => Some(anchor_range),
(None, Some(heading_range)) => Some(heading_range),
(None, None) => None,
}

None
}

/// Whether or not there's an active command block selection.
Expand Down
Loading