diff --git a/app/src/ai/ai_document_view.rs b/app/src/ai/ai_document_view.rs
index 96c9e306c8a..70d5ebec2f0 100644
--- a/app/src/ai/ai_document_view.rs
+++ b/app/src/ai/ai_document_view.rs
@@ -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(
diff --git a/app/src/code/editor/comment_editor.rs b/app/src/code/editor/comment_editor.rs
index 9e39529a1ac..eb26ea98cf3 100644
--- a/app/src/code/editor/comment_editor.rs
+++ b/app/src/code/editor/comment_editor.rs
@@ -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);
diff --git a/app/src/code/editor/comment_editor_tests.rs b/app/src/code/editor/comment_editor_tests.rs
index 531bbc21fa8..ddcd1628e37 100644
--- a/app/src/code/editor/comment_editor_tests.rs
+++ b/app/src/code/editor/comment_editor_tests.rs
@@ -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(
diff --git a/app/src/notebooks/editor/model.rs b/app/src/notebooks/editor/model.rs
index f585fd40fba..6132c72c120 100644
--- a/app/src/notebooks/editor/model.rs
+++ b/app/src/notebooks/editor/model.rs
@@ -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;
@@ -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 ``/`` 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 `` as a delimiter
+ /// pair (see `markdown_parser::parse_inline_token_html_anchor_start`) — a bare ``/`` 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),
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,
}
#[derive(Clone)]
@@ -149,6 +167,79 @@ fn render_mermaid_clipboard_html(source: &str) -> Option {
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 ``/`` 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 `` 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> {
+ 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()
@@ -228,6 +319,7 @@ impl NotebooksEditorModel {
resize_tx,
file_link_resolution_context: None,
default_mermaid_display_mode: MarkdownDisplayMode::Raw,
+ pending_anchor: None,
}
}
@@ -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);
}
_ => (),
}
@@ -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) -> 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,
@@ -1348,35 +1466,62 @@ impl NotebooksEditorModel {
true
}
+ /// Resolve a `#fragment` against the document's addressable targets: explicit ``/`` 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> {
- 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.
diff --git a/app/src/notebooks/editor/model_tests.rs b/app/src/notebooks/editor/model_tests.rs
index 70121ff9408..47ea2c71cc6 100644
--- a/app/src/notebooks/editor/model_tests.rs
+++ b/app/src/notebooks/editor/model_tests.rs
@@ -103,7 +103,7 @@ fn model_from_markdown(
let (window, _) = 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_model = ctx.add_model(|ctx| {
let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
NotebooksEditorModel::new(styles, window_id, ctx)
@@ -580,6 +580,403 @@ fn test_find_matching_header_missing_returns_none() {
})
}
+#[test]
+fn test_pending_anchor_drains_and_scrolls_on_match() {
+ // The deferred cross-document scroll: a pending anchor set before layout is drained once,
+ // resolving through the same slug matcher as the same-document jump. A hyphenated fragment
+ // must land on its spaced heading, and the pending state must clear after draining.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("Intro\n\n## Target Section\nBody", &mut app, true);
+
+ editor.update(&mut app, |editor, ctx| {
+ editor.set_pending_anchor("target-section".to_owned());
+ assert!(
+ editor.drain_pending_anchor(ctx),
+ "Pending anchor matching a heading should request a scroll"
+ );
+ // Draining is one-shot: a second drain finds nothing pending and reports no scroll.
+ assert!(
+ !editor.drain_pending_anchor(ctx),
+ "Pending anchor should be cleared after the first drain"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_pending_anchor_miss_is_silent_no_op() {
+ // A pending anchor with no matching heading drains without scrolling and without error,
+ // mirroring the same-document miss semantics (product invariant 7).
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("## Target Section\nBody", &mut app, true);
+
+ editor.update(&mut app, |editor, ctx| {
+ editor.set_pending_anchor("no-such-heading".to_owned());
+ assert!(
+ !editor.drain_pending_anchor(ctx),
+ "A pending anchor with no matching heading must not scroll"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_slug_hyphenated_fragment() {
+ // The issue's headline case: a hyphenated fragment resolves against a spaced heading.
+ // Before the slug fix this returned None (exact-text comparison).
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("## Target Section\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ let range = editor
+ .find_matching_header("#target-section", ctx)
+ .expect("Hyphenated fragment should match spaced heading");
+ let heading = editor
+ .content
+ .as_ref(ctx)
+ .text_in_range(range.start + 1..range.end)
+ .into_string();
+ assert_eq!(heading, "Target Section");
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_strips_punctuation() {
+ // GitHub-style slugging drops punctuation from both the heading and the fragment.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("## What's New? (v2)\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor.find_matching_header("#whats-new-v2", ctx).is_some());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_unicode_accented() {
+ // The normalizer preserves accented Latin (not an ASCII-only filter).
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("## Café Société\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor.find_matching_header("#café-société", ctx).is_some());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_unicode_cjk() {
+ // CJK headings slug to themselves — no ASCII case to fold, no characters to strip.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("## 日本語\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor.find_matching_header("#日本語", ctx).is_some());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_html_and_markdown_equivalent() {
+ // Invariant 3: an `` and a markdown `[text](#slug)` targeting the same
+ // heading resolve to the same range — both reach find_matching_header via the same path.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown(
+ "html\n\n[md](#target-section)\n\n## Target Section\nBody",
+ &mut app,
+ true,
+ );
+
+ editor.read(&app, |editor, ctx| {
+ let html_range = editor
+ .find_matching_header("#target-section", ctx)
+ .expect("Fragment should resolve");
+ // Both syntaxes carry the identical fragment string, so both resolve identically.
+ assert_eq!(
+ editor.find_matching_header("#target-section", ctx),
+ Some(html_range)
+ );
+ });
+ })
+}
+
+#[test]
+fn test_heading_slug_pure_function() {
+ // Pure `&str -> String` normalizer, table-driven per the spec's testing section.
+ let cases = [
+ ("Target Section", "target-section"),
+ ("Simple", "simple"),
+ ("What's New? (v2)", "whats-new-v2"),
+ (" Leading and trailing ", "leading-and-trailing"),
+ ("Already-Hyphenated", "already-hyphenated"),
+ // Unicode-preserving, genuinely GitHub-compatible (not ASCII-only):
+ ("Café Société", "café-société"),
+ ("日本語", "日本語"),
+ ("Section 日本語 Café", "section-日本語-café"),
+ ];
+ for (input, expected) in cases {
+ assert_eq!(super::heading_slug(input), expected, "slug of {input:?}");
+ }
+}
+
+// --- ``/`` anchor-target resolution ---
+//
+// Characterization first: the phase-1 inline parser only recognizes `` as a delimiter
+// pair (`parse_inline_token_html_anchor_start` requires an `href` attribute to match at all).
+// A bare ``/`` with no `href` never becomes that token, falls through the
+// `alt` parser chain to literal text, and its raw markup — including the id/name value — survives
+// verbatim in the buffer. This is confirmed at the parser level by
+// `markdown_parser_tests::test_parse_html_anchor_unterminated_falls_back_to_text`'s trailing case
+// (`` -> `FormattedTextFragment::plain_text("")`). The tests below
+// exercise that same fact end-to-end through the editor buffer, which is what makes the
+// zero-cache, live-text-scan resolution mechanism possible: there is no separate anchor token to
+// carry from parse time to click time, because the tag's literal text already survives the trip.
+
+#[test]
+fn test_anchor_tag_survives_as_literal_text_in_buffer() {
+ // Characterization: an ``/`` tag with no `href` is not parsed into any special
+ // token — it round-trips into the buffer as plain visible text, exactly as authored.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("\n\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ let text = editor.content.as_ref(ctx).text().into_string();
+ assert!(
+ text.contains(""),
+ "anchor tag markup should survive verbatim in the buffer, got: {text:?}"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_resolves_anchor_id() {
+ // Invariant: `#custom-anchor` resolves against a bare `` marker
+ // with no heading involved at all.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown(
+ "Intro\n\n\n\nBody",
+ &mut app,
+ true,
+ );
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor.find_matching_header("#custom-anchor", ctx).is_some());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_resolves_anchor_name() {
+ // The `name` attribute is an equally valid anchor-target form (older HTML convention).
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown(
+ "Intro\n\n\n\nBody",
+ &mut app,
+ true,
+ );
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor.find_matching_header("#custom-anchor", ctx).is_some());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_anchor_id_matches_verbatim_not_slugged() {
+ // No id rewriting: an explicit `` value is matched exactly as authored, not run through
+ // `heading_slug`. A mixed-case, underscore-bearing id would be mangled by the heading
+ // normalizer (which lowercases and treats `_` as a non-alphanumeric drop candidate is wrong
+ // here) — matching it verbatim is the point.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("\n\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(
+ editor
+ .find_matching_header("#Custom_Anchor-1", ctx)
+ .is_some(),
+ "exact-case id should resolve"
+ );
+ assert!(
+ editor
+ .find_matching_header("#custom_anchor-1", ctx)
+ .is_none(),
+ "lowercased fragment must NOT match a differently-cased explicit id — anchor ids \
+ are not slug-normalized"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_anchor_and_heading_first_in_document_order_wins() {
+ // Frederic's ruling: anchors and headings share one namespace; when both match the same
+ // fragment, whichever occurs FIRST in document order wins — not "anchors always win."
+ // Case 1: the anchor appears before the colliding heading, so the anchor wins.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown(
+ "\n\nIntro\n\n## Target\nBody",
+ &mut app,
+ true,
+ );
+
+ editor.read(&app, |editor, ctx| {
+ let range = editor
+ .find_matching_header("#target", ctx)
+ .expect("should resolve to the earlier anchor");
+ // The anchor tag sits before the heading; assert the resolved range starts before the
+ // heading block by checking it's the anchor tag's position, not the heading's.
+ let heading_range = {
+ let content = editor.content.as_ref(ctx);
+ content
+ .outline_blocks()
+ .find(|o| {
+ matches!(
+ &o.block_type,
+ BlockType::Text(BufferBlockStyle::Header { .. })
+ )
+ })
+ .map(|o| o.start..o.end)
+ .expect("document has one heading")
+ };
+ assert!(
+ range.start < heading_range.start,
+ "anchor occurs earlier in the document and must win"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_heading_before_anchor_wins() {
+ // Case 2: the heading appears FIRST and the colliding anchor comes later — document order
+ // means the heading wins here, demonstrating this is not an "anchors always win" rule.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor =
+ model_from_markdown("## Target\nBody\n\n", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ let range = editor
+ .find_matching_header("#target", ctx)
+ .expect("should resolve to the earlier heading");
+ let content = editor.content.as_ref(ctx);
+ let heading_range = content
+ .outline_blocks()
+ .find(|o| {
+ matches!(
+ &o.block_type,
+ BlockType::Text(BufferBlockStyle::Header { .. })
+ )
+ })
+ .map(|o| o.start..o.end)
+ .expect("document has one heading");
+ assert_eq!(
+ range, heading_range,
+ "heading occurs earlier in the document and must win over the later anchor"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_anchor_miss_is_silent_none() {
+ // A fragment matching neither an anchor nor a heading is a silent miss, same as the
+ // heading-only miss path (product invariant 7).
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("\n\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor
+ .find_matching_header("#no-such-anchor", ctx)
+ .is_none());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_urlencoded_fragment_matches_anchor_id() {
+ // A URL-encoded fragment (e.g. produced by a browser-style link) decodes before comparison,
+ // matching an anchor id containing characters that require encoding.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("\n\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(
+ editor
+ .find_matching_header("#custom%20anchor", ctx)
+ .is_some(),
+ "urlencoded fragment should decode and match the anchor id verbatim"
+ );
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_header_anchor_single_quotes() {
+ // The anchor-tag scan accepts single-quoted attribute values too, mirroring the ``
+ // parser's support for both quote styles.
+ App::test((), |mut app| async move {
+ initialize_deps(&mut app);
+ let editor = model_from_markdown("\n\nBody", &mut app, true);
+
+ editor.read(&app, |editor, ctx| {
+ assert!(editor.find_matching_header("#custom-anchor", ctx).is_some());
+ });
+ })
+}
+
+#[test]
+fn test_find_matching_anchor_tag_pure_function() {
+ // Pure function test for the underlying scanner, independent of the editor model. The
+ // matched range covers the opening tag through its id/name attribute value (the regex has no
+ // `>`/`` requirement) — enough to identify a scroll-to position; only `.start` is used by
+ // callers, so the exact end isn't load-bearing, but it's asserted here to pin the contract.
+ assert_eq!(
+ super::find_matching_anchor_tag(r#""#, "foo"),
+ Some(CharOffset::from(0)..CharOffset::from(11))
+ );
+ assert_eq!(
+ super::find_matching_anchor_tag(r#""#, "foo"),
+ Some(CharOffset::from(0)..CharOffset::from(13))
+ );
+ assert_eq!(
+ super::find_matching_anchor_tag(r#""#, "bar"),
+ None
+ );
+ assert_eq!(
+ super::find_matching_anchor_tag(r#""#, ""),
+ None
+ );
+ // First occurrence wins among multiple anchors sharing the same id.
+ let text = r#" middle "#;
+ let first = super::find_matching_anchor_tag(text, "dup").expect("should match");
+ assert_eq!(first.start, CharOffset::from(0));
+ // A non-ASCII prefix before the tag must not misplace the char offset — regression guard for
+ // the byte-vs-char distinction `CharCounter` bridges.
+ let text_with_unicode_prefix = "日本語 ";
+ let range = super::find_matching_anchor_tag(text_with_unicode_prefix, "foo")
+ .expect("should match after unicode prefix");
+ // "日本語 " is 4 chars (3 CJK + 1 space); the tag starts at char offset 4.
+ assert_eq!(range.start, CharOffset::from(4));
+}
+
#[test]
fn test_cursor_bias_editing() {
App::test((), |mut app| async move {
diff --git a/app/src/notebooks/editor/view.rs b/app/src/notebooks/editor/view.rs
index b6797620f68..e898b35aa0b 100644
--- a/app/src/notebooks/editor/view.rs
+++ b/app/src/notebooks/editor/view.rs
@@ -1973,17 +1973,21 @@ impl RichTextEditorView {
};
if let Some(url) = url {
- if url.starts_with('#')
- && (cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable))
- {
- let scrolled = self
- .model
- .update(ctx, |model, ctx| model.scroll_to_matching_header(&url, ctx));
- if scrolled {
- self.open_link = None;
- ctx.notify();
- return;
+ if url.starts_with('#') {
+ // An in-document `#fragment` is resolved entirely within the viewer and never
+ // falls through to the URL/file opener. On an activating interaction (cmd-click,
+ // or a plain click in a read-only Selectable chip) a hit scrolls to the anchor.
+ // A miss — and every non-activating interaction — is a deliberate no-op: the
+ // fragment must not be handed to the resolver, which would treat it as a bogus
+ // relative file path and surface a broken-link tooltip (violating invariant 7's
+ // "no error on miss").
+ if cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable) {
+ self.model
+ .update(ctx, |model, ctx| model.scroll_to_matching_header(&url, ctx));
}
+ self.open_link = None;
+ ctx.notify();
+ return;
}
// In read-only comment chips (Selectable), open the link directly on
// click instead of showing a tooltip.
diff --git a/app/src/notebooks/editor/view_tests.rs b/app/src/notebooks/editor/view_tests.rs
index 1c42289b1dc..a3941dd5517 100644
--- a/app/src/notebooks/editor/view_tests.rs
+++ b/app/src/notebooks/editor/view_tests.rs
@@ -106,7 +106,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_model = ctx.add_model(|ctx| {
let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
NotebooksEditorModel::new(styles, window_id, ctx)
@@ -661,7 +661,13 @@ fn test_link_editing() {
}
#[test]
-fn test_editable_markdown_anchor_click_opens_link_tooltip() {
+fn test_editable_markdown_anchor_click_is_silent_no_op() {
+ // An in-document `#fragment` is resolved entirely within the viewer (see `maybe_open_url`'s
+ // `#`-branch) and never surfaces a link tooltip. A plain (non-cmd) click in an editable
+ // editor is a non-activating interaction, so it does nothing observable: no tooltip, no
+ // scroll, no fall-through to the URL/file opener. (Superseded: the pre-anchor-links behavior
+ // showed an editable `#goal` link tooltip here; the fragment now belongs to the resolver, not
+ // the tooltip path.)
App::test((), |mut app| async move {
let (_, editor_view, _) = initialize_editor(&mut app);
reset_editor_with_markdown(&mut app, &editor_view, "- [Goal](#goal)\n\n## Goal").await;
@@ -679,12 +685,10 @@ fn test_editable_markdown_anchor_click_opens_link_tooltip() {
});
editor_view.read(&app, |editor, _ctx| {
- let open_link = editor
- .open_link
- .as_ref()
- .expect("Editable anchor click should show the link tooltip");
- assert_eq!(open_link.url, "#goal");
- assert!(open_link.editable);
+ assert!(
+ editor.open_link.is_none(),
+ "An in-document `#fragment` click must not show a link tooltip"
+ );
});
});
}
@@ -717,10 +721,19 @@ fn test_cmd_click_markdown_anchor_navigates_without_link_tooltip() {
}
#[test]
-fn test_cmd_click_missing_markdown_anchor_falls_back_to_link_resolution() {
+fn test_cmd_click_missing_markdown_anchor_is_silent_no_op() {
+ // Product invariant 7: a `#fragment` that matches no heading does nothing observable — no
+ // scroll, no error, and crucially "no attempt to open it as an external URL". The fragment
+ // must NOT fall through to the link resolver, which would treat `#missing.png` as a bogus
+ // relative file path and emit an open event / broken-link tooltip. (Superseded: the
+ // pre-anchor-links behavior fell back to link resolution here and emitted `OpenFileWithTarget`
+ // for a literal `#missing.png` file; that fall-through is exactly what the anchor work
+ // removed.)
App::test((), |mut app| async move {
let (window_id, editor_view, _) = initialize_editor(&mut app);
let base = tempdir().expect("Expected temp dir");
+ // A file literally named `#missing.png` would have been the fall-through target under the
+ // old behavior. It must now be ignored entirely — no event for it.
let fallback_path = base.path().join("#missing.png");
std::fs::File::create(&fallback_path).expect("Expected fallback file");
let session = Arc::new(Session::test().with_shell_launch_data(
@@ -770,15 +783,20 @@ fn test_cmd_click_missing_markdown_anchor_falls_back_to_link_resolution() {
);
});
- assert_eventually!(
- events.lock().iter().any(|event| {
- matches!(
- event,
- LinkEvent::OpenFileWithTarget { path, .. } if path == &fallback_path
- )
- }),
- "Missing anchor click should fall back to link resolution: {:?}",
- events.lock().clone()
+ // The `#`-branch handles the fragment synchronously and returns before spawning any
+ // resolution future, so there is nothing async to await: if the branch is correct, no
+ // event was emitted and no tooltip was set by the time `handle_action` returned.
+ editor_view.read(&app, |editor, _ctx| {
+ assert!(
+ editor.open_link.is_none(),
+ "A `#fragment` miss must not surface a link tooltip"
+ );
+ });
+ let emitted = events.lock();
+ assert!(
+ emitted.is_empty(),
+ "A `#fragment` miss must not fall through to link resolution (invariant 7), \
+ but emitted: {emitted:?}"
);
});
}
diff --git a/app/src/notebooks/file/mod.rs b/app/src/notebooks/file/mod.rs
index b1d6ec168c9..5d8d7261570 100644
--- a/app/src/notebooks/file/mod.rs
+++ b/app/src/notebooks/file/mod.rs
@@ -245,7 +245,7 @@ impl FileNotebookView {
pub fn new(ctx: &mut ViewContext) -> Self {
let window_id = ctx.window_id();
// Use the active session for links until we have something more specific.
- 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 view_position_id = format!("file_notebook_view_{}", ctx.view_id());
@@ -434,6 +434,18 @@ impl FileNotebookView {
self.pane_configuration.update(ctx, |pane_config, ctx| {
pane_config.set_title(local_path.display().to_string(), ctx);
});
+ // Even without a session, seed the link resolver with the document's own parent
+ // directory as a base-directory fallback, so a standalone viewer tab (e.g.
+ // `open -a Warp file.md`, which has no terminal session cwd) can still resolve this
+ // document's relative links against where the document actually lives.
+ let window_id = ctx.window_id();
+ let document_dir = local_path.parent().map(Path::to_path_buf);
+ self.links.update(ctx, |links, ctx| {
+ links.set_session_source(
+ SessionSource::active_for_document(window_id, document_dir),
+ ctx,
+ );
+ });
}
self.file_state = FileState::Loading(SourceFile::FileBased {
@@ -714,6 +726,30 @@ impl FileNotebookView {
self.links.clone()
}
+ /// Immediately scroll to a `#fragment` anchor's heading. Used when a cross-document link
+ /// targets a notebook whose tab is *already* open and laid out, so no deferral is needed.
+ /// A miss (no matching heading) is a silent no-op, matching same-document miss semantics.
+ pub fn scroll_to_anchor(&self, anchor: &str, ctx: &mut ViewContext) {
+ let fragment = format!("#{anchor}");
+ self.editor.update(ctx, |editor, ctx| {
+ editor.model().update(ctx, |model, ctx| {
+ model.scroll_to_matching_header(&fragment, ctx);
+ });
+ });
+ }
+
+ /// Queue a `#fragment` anchor to scroll to once this notebook's layout is first built. Used
+ /// when a cross-document link opens the target in a *new* tab: the heading offset does not
+ /// exist until the buffer parses and lays out. The pending anchor is drained on the first
+ /// `LayoutUpdated` inside the editor model.
+ pub fn queue_pending_anchor(&self, anchor: String, ctx: &mut ViewContext) {
+ self.editor.update(ctx, |editor, ctx| {
+ editor.model().update(ctx, |model, _| {
+ model.set_pending_anchor(anchor);
+ });
+ });
+ }
+
fn is_markdown_file(&self) -> bool {
self.file_state
.path()
diff --git a/app/src/notebooks/link.rs b/app/src/notebooks/link.rs
index d9920b1c599..642df7f2bb2 100644
--- a/app/src/notebooks/link.rs
+++ b/app/src/notebooks/link.rs
@@ -33,9 +33,16 @@ pub enum LinkTarget {
LocalFile {
path: PathBuf,
line_and_column: Option,
- /// The base session when the link was resolved. It's stored here in case it changes
- /// between resolving and opening the link.
- session: Arc,
+ /// A `#fragment` anchor to scroll to once the destination Markdown document loads.
+ /// Split off the link before file resolution (so `other-file.md#section` resolves the
+ /// file, not a literal `#section` path) and carried here so the destination notebook can
+ /// perform a deferred scroll — the cross-document analog of `line_and_column`.
+ anchor: Option,
+ /// The base session when the link was resolved, if there was one. Stored here in case it
+ /// changes between resolving and opening the link. `None` for a link resolved in a
+ /// standalone Markdown viewer tab (opened from Finder / `open -a Warp file.md`), which has
+ /// a base directory — the document's own dir — but no terminal session.
+ session: Option>,
/// Whether or not this file is a Markdown file viewable in Warp.
is_markdown: bool,
},
@@ -73,19 +80,28 @@ impl PartialEq for LinkTarget {
Self::LocalFile {
path: my_path,
line_and_column: my_location,
+ anchor: my_anchor,
session: my_session,
..
},
Self::LocalFile {
path: other_path,
line_and_column: other_location,
+ anchor: other_anchor,
session: other_session,
..
},
) => {
my_path == other_path
&& my_location == other_location
- && Arc::ptr_eq(my_session, other_session)
+ && my_anchor == other_anchor
+ && match (my_session, other_session) {
+ (Some(my_session), Some(other_session)) => {
+ Arc::ptr_eq(my_session, other_session)
+ }
+ (None, None) => true,
+ _ => false,
+ }
}
(Self::LocalDirectory { path: my_path }, Self::LocalDirectory { path: other_path }) => {
my_path == other_path
@@ -105,6 +121,51 @@ impl fmt::Display for LinkTarget {
}
}
+/// Split a trailing `#fragment` anchor off a scheme-less link target.
+///
+/// Returns the path portion and the decoded anchor (if any). A `#L[:]`
+/// line-number suffix is intentionally *not* treated as an anchor — it is left on the path so the
+/// downstream `CleanPathResult` line/column parser continues to handle it. An empty fragment
+/// (`file.md#`) yields no anchor. Only the final `#` is split, so an earlier `#` in the path
+/// (e.g. `weird#name.md`) is preserved.
+fn split_anchor_fragment(link: &str) -> (&str, Option) {
+ let Some((path, fragment)) = link.rsplit_once('#') else {
+ return (link, None);
+ };
+
+ // Preserve `#L100` / `#L100:200` line-number routing: these are stripped by
+ // `CleanPathResult`, so they must stay attached to the path, not be peeled as an anchor.
+ if is_line_number_fragment(fragment) {
+ return (link, None);
+ }
+
+ if fragment.is_empty() {
+ return (path, None);
+ }
+
+ let anchor = urlencoding::decode(fragment)
+ .map(|decoded| decoded.into_owned())
+ .unwrap_or_else(|_| fragment.to_owned());
+ (path, Some(anchor))
+}
+
+/// Whether a fragment is a `#L` or `#L:` line-number suffix (handled by
+/// `CleanPathResult`), rather than a document anchor.
+fn is_line_number_fragment(fragment: &str) -> bool {
+ let Some(rest) = fragment.strip_prefix('L') else {
+ return false;
+ };
+ match rest.split_once(':') {
+ Some((line, col)) => {
+ !line.is_empty()
+ && line.bytes().all(|b| b.is_ascii_digit())
+ && !col.is_empty()
+ && col.bytes().all(|b| b.is_ascii_digit())
+ }
+ None => !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()),
+ }
+}
+
/// Model for resolving and opening links in a notebook, taking into account their context (for
/// example, resolving relative file paths).
pub struct NotebookLinks {
@@ -140,26 +201,67 @@ impl NotebookLinks {
&& let Ok(file) = url.to_file_path()
{
// TODO(ben): Support line and column in file:// URLs.
- return Either::Left(Self::resolve_file(file, session, None));
+ return Either::Left(Self::resolve_file(file, Some(session), None, None));
}
}
return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
}
- // If parsing failed, see if this is a web URL without a scheme.
- // The heuristic we use is to take the substring up to the first slash (if present), and
- // check for a valid public domain name or IP address.
- let maybe_domain = link.split_once('/').map_or(link, |(start, _)| start);
- if (addr::parse_domain_name(maybe_domain)
- .is_ok_and(|domain| domain.has_known_suffix() && domain.root().is_some())
- || maybe_domain.parse::().is_ok())
- && let Ok(url) = Url::parse(&format!("http://{link}"))
- {
- return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
+ // Peel a trailing `#fragment` off before any file resolution, so a cross-document link
+ // like `other-file.md#section` resolves the file (not a literal `#section` path) and the
+ // fragment rides along on the resolved target for a deferred scroll. A `#L100`
+ // line-number suffix is deliberately left in place — it is handled downstream by
+ // `CleanPathResult` and must not be mistaken for an anchor. (A bare `#fragment` with no
+ // path never reaches here: it is intercepted earlier by the viewer's `maybe_open_url`
+ // `#`-branch.)
+ let (link, anchor) = split_anchor_fragment(link);
+
+ // REPAIR: a bare `file.md` (no `./`, no `/`) is classified as a valid public domain by
+ // the heuristic below, because `.md` (Moldova), `.dev`, `.com`, … are known suffixes.
+ // That misroutes an existing local Markdown file to the browser. So before applying the
+ // domain heuristic, if the scheme-less target resolves to an existing file relative to
+ // the base directory, treat it as a file. A genuine bare domain with no matching local
+ // file (e.g. `warp.dev`) still falls through to the browser.
+ #[cfg(feature = "local_fs")]
+ let resolves_to_local_file = {
+ let session = self.session_source.session(ctx);
+ match self.session_source.base_directory(ctx) {
+ Some(base_directory) => {
+ let clean_path = CleanPathResult::with_line_and_column_number(link);
+ crate::util::file::absolute_path_if_valid(
+ &clean_path,
+ crate::util::file::ShellPathType::PlatformNative(
+ base_directory.to_path_buf(),
+ ),
+ session.as_ref().and_then(|s| s.launch_data()),
+ )
+ .is_some()
+ }
+ None => false,
+ }
+ };
+ #[cfg(not(feature = "local_fs"))]
+ let resolves_to_local_file = false;
+
+ if !resolves_to_local_file {
+ // If parsing failed, see if this is a web URL without a scheme.
+ // The heuristic we use is to take the substring up to the first slash (if present), and
+ // check for a valid public domain name or IP address.
+ let maybe_domain = link.split_once('/').map_or(link, |(start, _)| start);
+ if (addr::parse_domain_name(maybe_domain)
+ .is_ok_and(|domain| domain.has_known_suffix() && domain.root().is_some())
+ || maybe_domain.parse::().is_ok())
+ && let Ok(url) = Url::parse(&format!("http://{link}"))
+ {
+ return Either::Right(future::ready(Ok(LinkTarget::Url(url))));
+ }
}
- // At this point, we can only resolve file targets, which require a session.
+ // At this point, we can only resolve file targets. These are normally resolved against a
+ // session's context, but a standalone Markdown viewer tab has no session — only a base
+ // directory (the document's own dir). The second arm below handles that case so relative
+ // links still resolve there.
match self.session_source.session(ctx) {
Some(session) if session.launch_data().is_some() => {
let launch_data = session
@@ -200,11 +302,16 @@ impl NotebookLinks {
Either::Left(Self::resolve_file(
path,
- session,
+ Some(session),
clean_path.line_and_column_num,
+ anchor,
))
}
- Some(session) => {
+ session => {
+ // Either a session without launch data, or no session at all (a standalone
+ // Markdown viewer tab). Both resolve a relative path against the base directory,
+ // which must be present — the document's own directory supplies it in the
+ // no-session case.
let clean_path_result = CleanPathResult::with_line_and_column_number(link);
let clean_path = Path::new(&clean_path_result.path);
let path = if clean_path.is_relative() {
@@ -224,27 +331,31 @@ impl NotebookLinks {
path,
session,
clean_path_result.line_and_column_num,
+ anchor,
))
}
- None => Either::Right(future::ready(Err(ResolveError::MissingContext))),
}
}
- /// Resolve a file path into a [`LinkTarget`], checking if it exists.
+ /// Resolve a file path into a [`LinkTarget`], checking if it exists. `session` is `None` for a
+ /// standalone Markdown viewer tab, which resolves relative links against the document's own
+ /// directory without any terminal session.
async fn resolve_file(
path: PathBuf,
- session: Arc,
+ session: Option>,
line_and_column: Option,
+ anchor: Option,
) -> Result {
let metadata = async_fs::metadata(&path).await?;
Ok(if metadata.is_dir() {
- // Discard line/column information, which doesn't make sense for a directory.
+ // Discard line/column and anchor information, which don't make sense for a directory.
LinkTarget::LocalDirectory { path }
} else {
LinkTarget::LocalFile {
is_markdown: is_markdown_file(&path),
path,
line_and_column,
+ anchor,
session,
}
})
@@ -268,6 +379,7 @@ impl NotebookLinks {
LinkTarget::LocalFile {
path,
line_and_column,
+ anchor,
session,
is_markdown: true,
} => {
@@ -278,15 +390,27 @@ impl NotebookLinks {
{
let settings = EditorSettings::as_ref(ctx);
if *settings.prefer_markdown_viewer {
- ctx.emit(LinkEvent::OpenFileNotebook { path, session });
+ ctx.emit(LinkEvent::OpenFileNotebook {
+ path,
+ session,
+ anchor,
+ });
} else {
+ // The external editor / code viewer has no heading-slug concept, so the
+ // anchor is dropped here per the product non-goal: the file opens
+ // unscrolled.
+ let _ = anchor;
open_file(path, line_and_column, ctx);
}
}
#[cfg(not(feature = "local_fs"))]
{
- ctx.emit(LinkEvent::OpenFileNotebook { path, session });
+ ctx.emit(LinkEvent::OpenFileNotebook {
+ path,
+ session,
+ anchor,
+ });
}
}
LinkTarget::LocalFile {
@@ -344,7 +468,7 @@ impl NotebookLinks {
) {
// Re-resolve links against the new session info, especially if the working directory
// changed.
- if matches!(self.session_source, SessionSource::Active(_)) {
+ if matches!(self.session_source, SessionSource::Active { .. }) {
ctx.emit(LinkEvent::RefreshLinks);
}
}
@@ -439,7 +563,12 @@ pub enum LinkEvent {
/// Emitted when the view should open a Markdown file as a notebook.
OpenFileNotebook {
path: PathBuf,
- session: Arc,
+ /// The session the link was resolved from, if any. `None` for a link opened in a
+ /// standalone Markdown viewer tab, which has no terminal session.
+ session: Option>,
+ /// A `#fragment` anchor to scroll to once the destination notebook loads, if the link
+ /// carried one (`other-file.md#section`). `None` for a plain file link.
+ anchor: Option,
},
OpenWarpDriveLink {
open_warp_drive_args: OpenWarpDriveObjectArgs,
@@ -475,23 +604,55 @@ pub enum SessionSource {
base_directory: PathBuf,
},
/// Use the window's active session and working directory.
- Active(WindowId),
+ Active {
+ window_id: WindowId,
+ /// The open document's own parent directory, used as the base-directory fallback when the
+ /// window's active session has no local working directory. Without this, a Markdown
+ /// document opened in a standalone viewer (e.g. `open -a Warp file.md`, which has no
+ /// terminal session cwd) can't resolve its own relative links — the document knows where
+ /// it lives even when the window doesn't. The active session's cwd still takes precedence
+ /// when present, preserving existing behavior for notebooks opened inside a session.
+ document_dir: Option,
+ },
}
impl SessionSource {
+ /// Use the window's active session, with no document-directory fallback. For surfaces that
+ /// are not backed by a file on disk (comment editors, AI documents, in-memory notebooks).
+ pub fn active(window_id: WindowId) -> Self {
+ SessionSource::Active {
+ window_id,
+ document_dir: None,
+ }
+ }
+
+ /// Use the window's active session, falling back to the given document directory when the
+ /// active session has no local working directory. For file-backed Markdown notebooks.
+ pub fn active_for_document(window_id: WindowId, document_dir: Option) -> Self {
+ SessionSource::Active {
+ window_id,
+ document_dir,
+ }
+ }
+
fn session(&self, ctx: &AppContext) -> Option> {
match self {
SessionSource::Target { session, .. } => Some(session.clone()),
- SessionSource::Active(window_id) => ActiveSession::as_ref(ctx).session(*window_id),
+ SessionSource::Active { window_id, .. } => {
+ ActiveSession::as_ref(ctx).session(*window_id)
+ }
}
}
fn base_directory<'a>(&'a self, ctx: &'a AppContext) -> Option<&'a Path> {
match self {
SessionSource::Target { base_directory, .. } => Some(base_directory.as_path()),
- SessionSource::Active(window_id) => {
- ActiveSession::as_ref(ctx).path_if_local(*window_id)
- }
+ SessionSource::Active {
+ window_id,
+ document_dir,
+ } => ActiveSession::as_ref(ctx)
+ .path_if_local(*window_id)
+ .or(document_dir.as_deref()),
}
}
}
diff --git a/app/src/notebooks/link_tests.rs b/app/src/notebooks/link_tests.rs
index 86a9c16428e..84a7e0796d9 100644
--- a/app/src/notebooks/link_tests.rs
+++ b/app/src/notebooks/link_tests.rs
@@ -10,7 +10,7 @@ use url::Url;
use warp_util::path::LineAndColumnArg;
use warpui::{App, ModelHandle, SingletonEntity, WindowId};
-use super::{LinkTarget, NotebookLinks, ResolveError, SessionSource};
+use super::{split_anchor_fragment, LinkTarget, NotebookLinks, ResolveError, SessionSource};
use crate::notebooks::file::is_markdown_file;
use crate::notebooks::link::LinkEvent;
use crate::terminal::model::session::Session;
@@ -34,7 +34,32 @@ fn local_file(path: impl Into) -> LinkTarget {
is_markdown: is_markdown_file(&path),
path,
line_and_column: None,
- session: TEST_SESSION.clone(),
+ anchor: None,
+ session: Some(TEST_SESSION.clone()),
+ }
+}
+
+/// Like [`local_file`], but for the standalone-viewer case where the link was resolved without a
+/// terminal session (see [`init_link_model_no_session`]).
+fn local_file_no_session(path: impl Into) -> LinkTarget {
+ let path = path.into();
+ LinkTarget::LocalFile {
+ is_markdown: is_markdown_file(&path),
+ path,
+ line_and_column: None,
+ anchor: None,
+ session: None,
+ }
+}
+
+fn local_file_anchor(path: impl Into, anchor: &str) -> LinkTarget {
+ let path = path.into();
+ LinkTarget::LocalFile {
+ is_markdown: is_markdown_file(&path),
+ path,
+ line_and_column: None,
+ anchor: Some(anchor.to_owned()),
+ session: Some(TEST_SESSION.clone()),
}
}
@@ -47,7 +72,8 @@ fn local_file_location(path: impl Into, line: usize, column: Option) -> ModelHandle<
base_directory: dir.to_owned(),
},
// File links can't be resolved without a session, even if there's no working directory.
- None => SessionSource::Active(window_id),
+ None => SessionSource::active(window_id),
};
app.add_singleton_model(|ctx| {
let mut session = ActiveSession::default();
@@ -79,6 +105,23 @@ fn init_link_model(app: &mut App, base_directory: Option<&Path>) -> ModelHandle<
app.add_model(|ctx| NotebookLinks::new(source, ctx))
}
+/// Initialize the link resolver for a *standalone Markdown viewer* tab: a file-backed notebook
+/// whose window has no active terminal session, so the only base-directory context is the
+/// document's own directory (`SessionSource::active_for_document`). This mirrors the real GUI
+/// condition of opening a `.md` from Finder / `open -a Warp file.md`, which
+/// [`init_link_model`] cannot reproduce because it always installs a `TEST_SESSION`.
+fn init_link_model_no_session(app: &mut App, document_dir: &Path) -> ModelHandle {
+ initialize_settings_for_tests(app);
+
+ let window_id = WindowId::new();
+ // Register the window with ActiveSession, but with *no* session and no working directory, so
+ // `session(window_id)` and `path_if_local(window_id)` both return `None` — the standalone
+ // viewer case. The document directory is the sole base-directory source.
+ app.add_singleton_model(|_ctx| ActiveSession::default());
+ let source = SessionSource::active_for_document(window_id, Some(document_dir.to_owned()));
+ app.add_model(|ctx| NotebookLinks::new(source, ctx))
+}
+
async fn resolve(app: &App, links: &ModelHandle, link: &str) -> LinkTarget {
match links.read(app, |links, ctx| links.resolve(link, ctx)).await {
Ok(target) => target,
@@ -171,6 +214,173 @@ fn test_resolve_bare_url() {
});
}
+#[test]
+fn test_split_anchor_fragment_pure() {
+ // No fragment.
+ assert_eq!(
+ split_anchor_fragment("other-file.md"),
+ ("other-file.md", None)
+ );
+ // Simple fragment.
+ assert_eq!(
+ split_anchor_fragment("other-file.md#section"),
+ ("other-file.md", Some("section".to_owned()))
+ );
+ // Dot-slash prefix preserved on the path side.
+ assert_eq!(
+ split_anchor_fragment("./other-file.md#section"),
+ ("./other-file.md", Some("section".to_owned()))
+ );
+ // Only the final `#` splits; an earlier `#` stays in the path.
+ assert_eq!(
+ split_anchor_fragment("weird#name.md#frag"),
+ ("weird#name.md", Some("frag".to_owned()))
+ );
+ // Empty fragment yields no anchor.
+ assert_eq!(
+ split_anchor_fragment("other-file.md#"),
+ ("other-file.md", None)
+ );
+ // URL-encoded fragments are decoded.
+ assert_eq!(
+ split_anchor_fragment("doc.md#caf%C3%A9"),
+ ("doc.md", Some("café".to_owned()))
+ );
+ // `#L` line-number suffixes are NOT peeled as anchors — left for CleanPathResult.
+ assert_eq!(
+ split_anchor_fragment("main.rs#L100"),
+ ("main.rs#L100", None)
+ );
+ assert_eq!(
+ split_anchor_fragment("main.rs#L100:50"),
+ ("main.rs#L100:50", None)
+ );
+ // A fragment that merely starts with `L` but isn't a line number is a real anchor.
+ assert_eq!(
+ split_anchor_fragment("doc.md#License"),
+ ("doc.md", Some("License".to_owned()))
+ );
+ assert_eq!(
+ split_anchor_fragment("doc.md#L10x"),
+ ("doc.md", Some("L10x".to_owned()))
+ );
+}
+
+#[test]
+fn test_bare_markdown_file_prefers_local_file_over_cctld() {
+ // Regression test for the `.md`-is-Moldova ccTLD collision. A bare `README.md` (no `./`
+ // prefix, no slash) is classified as a valid public domain by the bare-domain heuristic
+ // (`.md` has a known suffix and `README` is a root), so before this fix it misrouted to the
+ // browser. When such a target resolves to an existing local file relative to the base
+ // directory, it must be opened as a file, not a URL.
+ App::test((), |mut app| async move {
+ let base = tempdir().unwrap();
+ let base_path = base.path();
+ touch(base_path.join("README.md")).await;
+ touch(base_path.join("notes.md")).await;
+ touch(base_path.join("docs/guide.md")).await;
+ let links = init_link_model(&mut app, Some(base_path));
+
+ // Single-segment `file.md` targets that exist on disk resolve as files, not URLs — the
+ // shape the ccTLD heuristic misroutes (the heuristic takes the substring up to the first
+ // `/`, so only a slash-free name can be mistaken for a domain).
+ assert_eq!(
+ resolve(&app, &links, "README.md").await,
+ local_file(base_path.join("README.md"))
+ );
+ assert_eq!(
+ resolve(&app, &links, "notes.md").await,
+ local_file(base_path.join("notes.md"))
+ );
+
+ // Guard: multi-segment `.md` paths were already safe (any `/` makes the pre-`/` substring
+ // a non-suffix string, so the heuristic can't fire) — assert the repair's file-first
+ // ordering doesn't regress them.
+ assert_eq!(
+ resolve(&app, &links, "docs/guide.md").await,
+ local_file(base_path.join("docs/guide.md"))
+ );
+ assert_eq!(
+ resolve(&app, &links, "./docs/guide.md").await,
+ local_file(base_path.join("docs/guide.md"))
+ );
+
+ // A genuine bare domain that does NOT resolve to a local file still opens the browser.
+ assert_eq!(
+ resolve(&app, &links, "warp.dev").await,
+ url("http://warp.dev")
+ );
+ // A bare `.md` target with no matching local file also falls through to the browser,
+ // preserving the domain heuristic where there's nothing to shadow it.
+ assert_eq!(
+ resolve(&app, &links, "nonexistent.md").await,
+ url("http://nonexistent.md")
+ );
+ });
+}
+
+#[test]
+fn test_split_fragment_before_file_resolution() {
+ // A cross-document link `other-file.md#section` must have its `#section` fragment split off
+ // before file resolution, so the file part resolves on disk and the fragment rides along on
+ // the resolved target for a deferred scroll. Before this fix, the literal `#section` was
+ // included in the stat and the file was never found.
+ App::test((), |mut app| async move {
+ let base = tempdir().unwrap();
+ let base_path = base.path();
+ touch(base_path.join("other-file.md")).await;
+ touch(base_path.join("multi#hash.md")).await;
+ let links = init_link_model(&mut app, Some(base_path));
+
+ // Bare `file.md#section` resolves to the file with the anchor attached.
+ assert_eq!(
+ resolve(&app, &links, "other-file.md#section").await,
+ local_file_anchor(base_path.join("other-file.md"), "section")
+ );
+ // `./`-prefixed form (which dodges the ccTLD heuristic) also splits the fragment.
+ assert_eq!(
+ resolve(&app, &links, "./other-file.md#section").await,
+ local_file_anchor(base_path.join("other-file.md"), "section")
+ );
+ // No fragment → no anchor.
+ assert_eq!(
+ resolve(&app, &links, "./other-file.md").await,
+ local_file(base_path.join("other-file.md"))
+ );
+ // Only the final `#…` is treated as the fragment; earlier `#` stays in the path.
+ assert_eq!(
+ resolve(&app, &links, "./multi#hash.md#frag").await,
+ local_file_anchor(base_path.join("multi#hash.md"), "frag")
+ );
+ // A trailing empty fragment (`file.md#`) resolves the file with no anchor.
+ assert_eq!(
+ resolve(&app, &links, "./other-file.md#").await,
+ local_file(base_path.join("other-file.md"))
+ );
+ });
+}
+
+#[test]
+fn test_fragment_split_preserves_line_number_routing() {
+ // The `#L100` line-number suffix must continue to route through the existing line/column
+ // path (handled by `CleanPathResult`), not be peeled as an anchor fragment.
+ App::test((), |mut app| async move {
+ let base = tempdir().unwrap();
+ let base_path = base.path();
+ touch(base_path.join("src/main.rs")).await;
+ let links = init_link_model(&mut app, Some(base_path));
+
+ assert_eq!(
+ resolve(&app, &links, "./src/main.rs#L100").await,
+ local_file_location(base_path.join("src/main.rs"), 100, None)
+ );
+ assert_eq!(
+ resolve(&app, &links, "./src/main.rs#L100:50").await,
+ local_file_location(base_path.join("src/main.rs"), 100, Some(50))
+ );
+ });
+}
+
#[test]
fn test_open_local_image_uses_system_generic_target() {
App::test((), |mut app| async move {
@@ -418,9 +628,9 @@ fn test_open_markdown_file_uses_viewer_when_preferred() {
links
.update(&mut app, |links, ctx| {
- // The `./` in the link is important: `.md` is the TLD for Moldova, so this will be
- // resolved as a web link otherwise.
- let future = links.resolve_and_open("./README.md", ctx);
+ // With the ccTLD repair, a bare `README.md` that exists on disk resolves as a
+ // file rather than the `.md` Moldova domain, so no `./` prefix is needed here.
+ let future = links.resolve_and_open("README.md", ctx);
ctx.await_spawned_future(future.future_id())
})
.await;
@@ -428,9 +638,135 @@ fn test_open_markdown_file_uses_viewer_when_preferred() {
let events = events.lock();
assert_eq!(events.len(), 1);
match events.first() {
- Some(LinkEvent::OpenFileNotebook { path, session }) => {
+ Some(LinkEvent::OpenFileNotebook {
+ path,
+ session,
+ anchor,
+ }) => {
assert_eq!(path, &root.join("README.md"));
- assert!(Arc::ptr_eq(&TEST_SESSION, session));
+ assert!(session
+ .as_ref()
+ .is_some_and(|s| Arc::ptr_eq(&TEST_SESSION, s)));
+ assert_eq!(anchor, &None);
+ }
+ other => panic!("Expected OpenFileNotebook event, got {other:?}"),
+ }
+ });
+}
+
+#[test]
+fn test_cross_document_fragment_threads_anchor_to_open_event() {
+ // A `file.md#section` cross-document link must carry its `section` anchor all the way to the
+ // emitted `OpenFileNotebook` event, so the destination notebook can perform a deferred
+ // scroll to the heading once it loads.
+ App::test((), |mut app| async move {
+ let base = tempdir().unwrap();
+ let base_path = base.path();
+ touch(base_path.join("other-file.md")).await;
+ let links = init_link_model(&mut app, Some(base_path));
+
+ let events = Arc::new(Mutex::new(vec![]));
+ {
+ let events = events.clone();
+ app.update(|ctx| {
+ ctx.subscribe_to_model(&links, move |_, event, _| {
+ events.lock().push(event.clone());
+ })
+ });
+ }
+
+ links
+ .update(&mut app, |links, ctx| {
+ let future = links.resolve_and_open("other-file.md#target-section", ctx);
+ ctx.await_spawned_future(future.future_id())
+ })
+ .await;
+
+ let events = events.lock();
+ assert_eq!(events.len(), 1);
+ match events.first() {
+ Some(LinkEvent::OpenFileNotebook {
+ path,
+ session,
+ anchor,
+ }) => {
+ assert_eq!(path, &base_path.join("other-file.md"));
+ assert!(session
+ .as_ref()
+ .is_some_and(|s| Arc::ptr_eq(&TEST_SESSION, s)));
+ assert_eq!(anchor.as_deref(), Some("target-section"));
+ }
+ other => panic!("Expected OpenFileNotebook event, got {other:?}"),
+ }
+ });
+}
+
+#[test]
+fn test_cross_document_link_resolves_without_session() {
+ // Regression (#13725): a standalone Markdown viewer tab — opened from Finder / `open -a Warp
+ // file.md` — has a valid base directory (the document's own dir) but *no* terminal session.
+ // Resolving a cross-document file link must still succeed against that base directory. Before
+ // the fix, `resolve` fell through to its session `match` and returned `Err(MissingContext)`
+ // for the no-session case even though the file existed, so every relative/cross-document link
+ // was a silent no-op in the standalone viewer while in-document `#fragment` links (which never
+ // touch the resolver) kept working.
+ App::test((), |mut app| async move {
+ let base = tempdir().unwrap();
+ let base_path = base.path();
+ touch(base_path.join("other-file.md")).await;
+ let links = init_link_model_no_session(&mut app, base_path);
+
+ let resolved = links
+ .read(&app, |links, ctx| links.resolve("other-file.md", ctx))
+ .await
+ .expect("cross-document link should resolve without a session");
+ assert_eq!(
+ resolved,
+ local_file_no_session(base_path.join("other-file.md"))
+ );
+ });
+}
+
+#[test]
+fn test_cross_document_link_opens_in_viewer_without_session() {
+ // The end-to-end standalone-viewer path: `resolve_and_open` must emit `OpenFileNotebook` (with
+ // no session) so the workspace opens the target document. This is the seam the live GUI failure
+ // exercised — `resolve_and_open` silently drops a resolve `Err`, so a session-gated failure
+ // produced no event and no visible action.
+ App::test((), |mut app| async move {
+ let base = tempdir().unwrap();
+ let base_path = base.path();
+ touch(base_path.join("other-file.md")).await;
+ let links = init_link_model_no_session(&mut app, base_path);
+
+ let events = Arc::new(Mutex::new(vec![]));
+ {
+ let events = events.clone();
+ app.update(|ctx| {
+ ctx.subscribe_to_model(&links, move |_, event, _| {
+ events.lock().push(event.clone());
+ })
+ });
+ }
+
+ links
+ .update(&mut app, |links, ctx| {
+ let future = links.resolve_and_open("other-file.md#far-section", ctx);
+ ctx.await_spawned_future(future.future_id())
+ })
+ .await;
+
+ let events = events.lock();
+ assert_eq!(events.len(), 1);
+ match events.first() {
+ Some(LinkEvent::OpenFileNotebook {
+ path,
+ session,
+ anchor,
+ }) => {
+ assert_eq!(path, &base_path.join("other-file.md"));
+ assert!(session.is_none());
+ assert_eq!(anchor.as_deref(), Some("far-section"));
}
other => panic!("Expected OpenFileNotebook event, got {other:?}"),
}
diff --git a/app/src/notebooks/notebook.rs b/app/src/notebooks/notebook.rs
index a846bdd9aab..d848bb5622f 100644
--- a/app/src/notebooks/notebook.rs
+++ b/app/src/notebooks/notebook.rs
@@ -337,7 +337,7 @@ impl NotebookView {
ctx.observe(&active_notebook_data, Self::handle_active_notebook_change);
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 title = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs
index b63634e23de..e0d0989d7a7 100644
--- a/app/src/pane_group/mod.rs
+++ b/app/src/pane_group/mod.rs
@@ -549,8 +549,12 @@ pub enum Event {
OpenFileInWarp {
/// The file path to open.
path: LocalOrRemotePath,
- /// The session that the path was opened from.
- session: Arc,
+ /// The session that the path was opened from, if any. `None` when the link was opened in a
+ /// standalone Markdown viewer tab, which has no terminal session.
+ session: Option>,
+ /// A `#fragment` anchor to scroll to once the destination Markdown notebook loads, if the
+ /// originating link carried one. `None` for a plain file open.
+ anchor: Option,
},
OpenWarpDriveLink {
open_warp_drive_args: OpenWarpDriveObjectArgs,
@@ -1746,6 +1750,7 @@ impl PaneGroup {
NotebookPaneSnapshot::LocalFileNotebook { path } => Box::new(FilePane::new(
path.map(LocalOrRemotePath::Local),
None,
+ None,
#[cfg(feature = "local_fs")]
None,
ctx,
@@ -5036,7 +5041,7 @@ impl PaneGroup {
}
});
- let file_pane = FilePane::new(Some(path), session, source, ctx);
+ let file_pane = FilePane::new(Some(path), session, None, source, ctx);
let success = self.replace_pane(code_pane_id, file_pane, false, ctx);
if !success {
diff --git a/app/src/pane_group/pane/file_pane.rs b/app/src/pane_group/pane/file_pane.rs
index 23f921b0bd5..5c8a74f8a84 100644
--- a/app/src/pane_group/pane/file_pane.rs
+++ b/app/src/pane_group/pane/file_pane.rs
@@ -40,9 +40,13 @@ impl FilePane {
/// is `None`, the pane is created but left empty. For local paths without a target session,
/// the pane waits for a local session to become active. Remote paths are loaded directly
/// via the remote server.
+ ///
+ /// `anchor` is an optional `#fragment` to scroll to once the notebook's Markdown layout is
+ /// built — set when a cross-document link (`other-file.md#section`) opens this pane.
pub fn new(
path: Option,
target_session: Option>,
+ anchor: Option,
#[cfg(feature = "local_fs")] code_source: Option,
ctx: &mut ViewContext,
) -> Self {
@@ -53,6 +57,12 @@ impl FilePane {
if let Some(path) = path {
view.open(path, target_session, ctx);
+ // Queue the deferred scroll before the file finishes loading: the layout that
+ // resolves the heading offset happens asynchronously, and the pending anchor is
+ // drained on the first `LayoutUpdated`.
+ if let Some(anchor) = anchor {
+ view.queue_pending_anchor(anchor, ctx);
+ }
}
view
diff --git a/app/src/pane_group/pane/notebook_pane.rs b/app/src/pane_group/pane/notebook_pane.rs
index d13d2521e66..60e49400037 100644
--- a/app/src/pane_group/pane/notebook_pane.rs
+++ b/app/src/pane_group/pane/notebook_pane.rs
@@ -172,11 +172,16 @@ pub(super) fn subscribe_to_link_model(
ctx: &mut ViewContext,
) {
ctx.subscribe_to_model(handle, move |pane_group, _, event, ctx| match event {
- LinkEvent::OpenFileNotebook { path, session } => {
+ LinkEvent::OpenFileNotebook {
+ path,
+ session,
+ anchor,
+ } => {
// Opening local files is delegated to the parent workspace.
ctx.emit(crate::pane_group::Event::OpenFileInWarp {
path: crate::code::buffer_location::LocalOrRemotePath::Local(path.clone()),
session: session.clone(),
+ anchor: anchor.clone(),
})
}
LinkEvent::OpenWarpDriveLink {
diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs
index ae2071ba924..1d5f7eb3d2c 100644
--- a/app/src/pane_group/pane/terminal_pane.rs
+++ b/app/src/pane_group/pane/terminal_pane.rs
@@ -1097,7 +1097,10 @@ fn handle_terminal_view_event(
Event::OpenFileInWarp { path, session } => {
ctx.emit(pane_group::Event::OpenFileInWarp {
path: LocalOrRemotePath::Local(path.clone()),
- session: session.clone(),
+ // A terminal always has a session backing the file-path click.
+ session: Some(session.clone()),
+ // Terminal file-path clicks never carry a Markdown anchor fragment.
+ anchor: None,
});
}
#[cfg(feature = "local_fs")]
diff --git a/app/src/workspace/home.rs b/app/src/workspace/home.rs
index 7dbd0e8c375..3f4c457fa2b 100644
--- a/app/src/workspace/home.rs
+++ b/app/src/workspace/home.rs
@@ -21,6 +21,7 @@ Warp on Web can also be used by your teammates and peers who don't have Warp dow
/// Create a static "home page" pane.
pub fn create_home_pane(ctx: &mut ViewContext) -> Box {
let pane = FilePane::new(
+ None,
None,
None,
#[cfg(feature = "local_fs")]
diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs
index 11ecae62f6d..d91f3ad9cb7 100644
--- a/app/src/workspace/view.rs
+++ b/app/src/workspace/view.rs
@@ -968,6 +968,24 @@ fn query_for_rewind_prefill(inputs: &[AIAgentInput]) -> Option {
inputs.iter().find_map(AIAgentInput::display_query)
}
+/// Canonicalize a local file target so it compares equal to an open notebook's stored path
+/// during dedup. Open notebooks record their *canonical* path on load (via `CanonicalizedPath`,
+/// i.e. `dunce::canonicalize`), while a clicked link resolves to `base_directory.join(relative)`
+/// — which keeps `.`/`..` components and, on macOS, the `/tmp` vs `/private/tmp` symlink alias.
+/// Canonicalizing with the same function lets a self-referential link (`./this-doc.md`) and any
+/// other spelling of the same file dedup to the already-open pane instead of opening a duplicate.
+/// Remote paths and paths that can't be canonicalized (e.g. the file vanished between resolve and
+/// open) are returned unchanged.
+#[cfg(feature = "local_fs")]
+fn canonicalize_local_path_for_dedup(path: LocalOrRemotePath) -> LocalOrRemotePath {
+ match &path {
+ LocalOrRemotePath::Local(local) => dunce::canonicalize(local)
+ .map(LocalOrRemotePath::Local)
+ .unwrap_or(path),
+ LocalOrRemotePath::Remote(_) => path,
+ }
+}
+
/// Snapshot of a tab used to move it between workspaces or into a new window.
/// Built by `Workspace::tab_transfer_info_at_index` and consumed by
/// `insert_transferred_tab_at_index`. Captures the pane group handle, visual
@@ -6277,6 +6295,7 @@ impl Workspace {
LocalOrRemotePath::Local(path.clone()),
session,
layout,
+ None,
ctx,
);
}
@@ -6390,7 +6409,7 @@ impl Workspace {
// Jupyter notebook) instead of always opening remote
// files as raw code in the editor.
if let FileTarget::MarkdownViewer(layout) = target {
- self.open_file_notebook(location.clone(), None, *layout, ctx);
+ self.open_file_notebook(location.clone(), None, *layout, None, ctx);
} else {
self.open_code(
code_source,
@@ -8483,8 +8502,17 @@ impl Workspace {
path: LocalOrRemotePath,
session: Option>,
layout: EditorLayout,
+ anchor: Option,
ctx: &mut ViewContext,
) {
+ // Canonicalize a local target before the dedup lookup. An open notebook stores its
+ // *canonical* path (recorded on load via `CanonicalizedPath`), but a link resolves to
+ // `base_directory.join(relative)`, which keeps `.`/`..` components and, on macOS, the
+ // `/tmp` vs `/private/tmp` symlink alias. Without canonicalizing, a self-referential
+ // link (`./this-doc.md`) wouldn't match its own open tab and would open a duplicate.
+ // Matching to canonical form makes self-reference — and any two spellings of the same
+ // file — dedup to the same pane.
+ let path = canonicalize_local_path_for_dedup(path);
let existing_file_pane = {
let pane_group = self.active_tab_pane_group();
pane_group
@@ -8494,18 +8522,25 @@ impl Workspace {
!pane_group.as_ref(ctx).is_pane_hidden_for_close(*pane_id)
&& file_view.as_ref(ctx).path() == Some(&path)
})
- .map(|(pane_id, _)| pane_id)
};
- if let Some(pane_id) = existing_file_pane {
+ if let Some((pane_id, file_view)) = existing_file_pane {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.focus_pane_by_id(pane_id, ctx);
});
+ // The tab is already open and laid out, so a cross-document anchor can scroll
+ // immediately — no deferral needed. A miss is a silent no-op.
+ if let Some(anchor) = anchor {
+ file_view.update(ctx, |view, ctx| {
+ view.scroll_to_anchor(&anchor, ctx);
+ });
+ }
return;
}
let pane = FilePane::new(
Some(path),
session,
+ anchor,
#[cfg(feature = "local_fs")]
None,
ctx,
@@ -16143,11 +16178,21 @@ impl Workspace {
);
}
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
- pane_group::Event::OpenFileInWarp { path, session } => {
+ pane_group::Event::OpenFileInWarp {
+ path,
+ session,
+ anchor,
+ } => {
#[cfg(feature = "local_fs")]
{
let layout = *EditorSettings::as_ref(ctx).open_file_layout.value();
- self.open_file_notebook(path.clone(), Some(session.clone()), layout, ctx);
+ self.open_file_notebook(
+ path.clone(),
+ session.clone(),
+ layout,
+ anchor.clone(),
+ ctx,
+ );
}
}
pane_group::Event::MoveToSpace {
diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs
index d7400ddb205..c481f89ce1d 100644
--- a/app/src/workspace/view_tests.rs
+++ b/app/src/workspace/view_tests.rs
@@ -4490,3 +4490,54 @@ fn test_tools_panel_warp_drive_toggle_updates_available_views() {
});
});
}
+
+#[cfg(feature = "local_fs")]
+#[test]
+fn test_canonicalize_local_path_for_dedup_normalizes_self_reference() {
+ // Self-referential dedup relies on `canonicalize_local_path_for_dedup` producing the same
+ // path an open notebook stores (which is canonicalized on load). This exercises the two
+ // ways a self-link's resolved path diverges from the stored canonical path: `.`/`..`
+ // components from `base_directory.join("./doc.md")`, and the macOS `/tmp` vs `/private/tmp`
+ // symlink alias this test environment actually hits.
+ let dir = tempfile::tempdir().expect("temp dir");
+ let doc = dir.path().join("doc.md");
+ std::fs::write(&doc, "# Doc\n").expect("write doc");
+
+ // The canonical form an open notebook would store for this file.
+ let canonical = dunce::canonicalize(&doc).expect("canonicalize doc");
+
+ // A `./`-relative resolution: base_directory + "./doc.md" keeps the `.` component.
+ let dot_relative = dir.path().join(".").join("doc.md");
+ assert_eq!(
+ canonicalize_local_path_for_dedup(LocalOrRemotePath::Local(dot_relative)),
+ LocalOrRemotePath::Local(canonical.clone()),
+ "A './'-relative self-link must canonicalize to the stored canonical path"
+ );
+
+ // A `..`-round-trip resolution: base + "sub/../doc.md" (sub need not exist for the string
+ // form, but canonicalize requires real components, so create it).
+ std::fs::create_dir(dir.path().join("sub")).expect("mkdir sub");
+ let dotdot_relative = dir.path().join("sub").join("..").join("doc.md");
+ assert_eq!(
+ canonicalize_local_path_for_dedup(LocalOrRemotePath::Local(dotdot_relative)),
+ LocalOrRemotePath::Local(canonical.clone()),
+ "A '..'-round-trip self-link must canonicalize to the stored canonical path"
+ );
+
+ // The raw tempdir path itself (which on macOS is under `/var/folders/.../T` or `/tmp`,
+ // possibly a symlink) must also canonicalize to the same target — guarding the symlink-alias
+ // case the addendum called out.
+ assert_eq!(
+ canonicalize_local_path_for_dedup(LocalOrRemotePath::Local(doc.clone())),
+ LocalOrRemotePath::Local(canonical),
+ "The as-resolved path must canonicalize to the stored canonical path (symlink alias)"
+ );
+
+ // A path that can't be canonicalized (does not exist) is returned unchanged, not dropped.
+ let missing = dir.path().join("does-not-exist.md");
+ assert_eq!(
+ canonicalize_local_path_for_dedup(LocalOrRemotePath::Local(missing.clone())),
+ LocalOrRemotePath::Local(missing),
+ "A non-existent path must pass through unchanged"
+ );
+}
diff --git a/crates/markdown_parser/src/markdown_parser.rs b/crates/markdown_parser/src/markdown_parser.rs
index 1e67e7f43da..4451a0f2faf 100644
--- a/crates/markdown_parser/src/markdown_parser.rs
+++ b/crates/markdown_parser/src/markdown_parser.rs
@@ -1035,6 +1035,28 @@ fn parse_inline<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
InlineToken::UnderlineEnd => {
input = parse_underline(&mut state, remaining);
}
+ InlineToken::HtmlAnchorStart { href, raw } => {
+ // Push the raw opening tag as the literal fallback (used if no `` closes it),
+ // mirroring how `LinkStart` stores its `[` placeholder, and stash the href on the
+ // delimiter for close-time styling.
+ let node_index = state.nodes.len();
+ let mut delimiter = Delimiter::new(
+ node_index,
+ DelimiterKind::HtmlAnchorStart,
+ 1,
+ state
+ .nodes
+ .last()
+ .and_then(|fragment| fragment.text.chars().last()),
+ remaining.chars().next(),
+ );
+ delimiter.anchor_href = Some(href);
+ state.push_closed_node(FormattedTextFragment::plain_text(raw));
+ state.delimiters.push(delimiter);
+ }
+ InlineToken::HtmlAnchorEnd => {
+ input = parse_html_anchor(&mut state, remaining);
+ }
}
}
@@ -1316,6 +1338,53 @@ fn parse_underline<'a>(state: &mut InlineState, remaining: &'a str) -> &'a str {
remaining
}
+/// Resolve a raw HTML `…` anchor when `` is encountered, using the same
+/// backtracking approach as [`parse_link`]. Applies [`Hyperlink::Url`] styling to the fragments
+/// between the opening tag and ``.
+fn parse_html_anchor<'a>(state: &mut InlineState, remaining: &'a str) -> &'a str {
+ let Some((anchor_start_index, anchor_start)) = state
+ .delimiters
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(_, delimiter)| delimiter.kind == DelimiterKind::HtmlAnchorStart)
+ else {
+ // No opening tag — treat this as literal `` text.
+ state.push_text("");
+ return remaining;
+ };
+
+ let anchor_start_node = anchor_start.node_index;
+ let href = anchor_start
+ .anchor_href
+ .clone()
+ .expect("HtmlAnchorStart delimiter must carry an href");
+
+ // Apply link styling to every fragment between the opening tag and ``.
+ state.backtrack_styles(anchor_start_node, |styles| {
+ styles.hyperlink = Some(Hyperlink::Url(href.clone()))
+ });
+
+ // Process inline styling within the anchor body, bounded by the opening tag.
+ process_emphasis(state, Some(anchor_start_index));
+
+ // Consume the opening delimiter and deactivate any prior link/anchor starts to prevent nesting.
+ state.delimiters.remove(anchor_start_index);
+ for delimiter in &mut state.delimiters[..anchor_start_index] {
+ if matches!(
+ delimiter.kind,
+ DelimiterKind::LinkStart | DelimiterKind::HtmlAnchorStart
+ ) {
+ delimiter.active = false;
+ }
+ }
+
+ // Drop the placeholder fragment holding the raw opening tag; the styling now lives on the body.
+ state.remove_node(anchor_start_node);
+ state.last_node_closed = true;
+ remaining
+}
+
/// Process emphasis delimiters on the state's delimiter stack, bounded by `stack_bottom`.
///
/// This is approximately equivalent to the CommonMark [process emphasis](https://spec.commonmark.org/0.30/#phase-2-inline-structure)
@@ -1549,6 +1618,8 @@ fn parse_inline_token<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
parse_inline_token_autolink,
parse_inline_token_underline_start,
parse_inline_token_underline_end,
+ parse_inline_token_html_anchor_start,
+ parse_inline_token_html_anchor_end,
whitespace,
text,
// This _must_ be the last parser in the chain. It unconditionally consumes a single
@@ -1654,6 +1725,80 @@ fn parse_inline_token_underline_end<'a, E: ContextError<&'a str> + ParseError<&'
)(input)
}
+/// Parse a single `key="value"` or `key='value'` HTML attribute, returning `(key, value)`.
+/// Whitespace-insensitive around `=`. Bare (valueless) attributes are not supported — every
+/// attribute the anchor grammar cares about (`href`) is always quoted in hand-authored markup.
+fn parse_html_attribute<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
+ input: &'a str,
+) -> IResult<&'a str, (&'a str, &'a str), E> {
+ let (input, key) =
+ take_while1(|c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_')(input)?;
+ let (input, _) = space0(input)?;
+ let (input, _) = char('=')(input)?;
+ let (input, _) = space0(input)?;
+ let (input, value) = alt((
+ delimited(char('"'), take_while(|c| c != '"'), char('"')),
+ delimited(char('\''), take_while(|c| c != '\''), char('\'')),
+ ))(input)?;
+ Ok((input, (key, value)))
+}
+
+/// Parse an opening `` tag into an [`InlineToken::HtmlAnchorStart`]. Requires an
+/// `href` attribute (the phase-1 hyperlink form); other attributes (`title`, `target`, `class`,
+/// …) are accepted and discarded. An `` tag with no `href` (e.g. a bare `` anchor
+/// target) does not match here — that is a separate phase-2 concern.
+fn parse_inline_token_html_anchor_start<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
+ input: &'a str,
+) -> IResult<&'a str, InlineToken<'a>, E> {
+ context("html_anchor_start", |input: &'a str| {
+ let (rest, (raw, href)) = consumed(|input: &'a str| {
+ let (input, _) = tag_no_case("` or similar.
+ let (input, _) = space1(input)?;
+
+ let mut input = input;
+ let mut href: Option<&'a str> = None;
+ loop {
+ let (next, _) = space0(input)?;
+ input = next;
+ if let Ok((next, _)) = char::<_, E>('>')(input) {
+ input = next;
+ break;
+ }
+ let (next, (key, value)) = parse_html_attribute(input)?;
+ if key.eq_ignore_ascii_case("href") {
+ href = Some(value);
+ }
+ input = next;
+ }
+
+ match href {
+ Some(href) => Ok((input, href)),
+ None => Err(nom::Err::Error(make_error(input, ErrorKind::Tag))),
+ }
+ })(input)?;
+
+ Ok((
+ rest,
+ InlineToken::HtmlAnchorStart {
+ href: href.to_string(),
+ raw,
+ },
+ ))
+ })(input)
+}
+
+/// Parse a closing `` into an [`InlineToken::HtmlAnchorEnd`].
+fn parse_inline_token_html_anchor_end<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
+ input: &'a str,
+) -> IResult<&'a str, InlineToken<'a>, E> {
+ context(
+ "html_anchor_end",
+ map(tag_no_case(""), |_| InlineToken::HtmlAnchorEnd),
+ )(input)
+}
+
/// Helper to parse a run of delimiters.
fn parse_delimiter_run<'a, E: ContextError<&'a str> + ParseError<&'a str>>(
kind: DelimiterKind,
@@ -1700,6 +1845,11 @@ enum InlineToken<'a> {
LinkEnd,
/// A closing , which triggers underline parsing.
UnderlineEnd,
+ /// An opening `` tag. Carries the parsed `href` value and the original tag text
+ /// (used as the literal fallback if no matching `` is found).
+ HtmlAnchorStart { href: String, raw: &'a str },
+ /// A closing ``, which triggers anchor parsing.
+ HtmlAnchorEnd,
}
/// An entry in the [delimiter stack](https://spec.commonmark.org/0.30/#delimiter-stack)
@@ -1720,6 +1870,10 @@ struct Delimiter {
can_open: bool,
/// Whether or not this delimiter can close a strong/emphasis range.
can_close: bool,
+ /// For a [`DelimiterKind::HtmlAnchorStart`], the `href` value of the opening `` tag,
+ /// applied as a [`Hyperlink::Url`] when the matching `` is parsed. `None` for every
+ /// other delimiter kind.
+ anchor_href: Option,
}
impl Delimiter {
@@ -1750,7 +1904,8 @@ impl Delimiter {
&& (!preceded_by_punctuation || (followed_by_whitespace || followed_by_punctuation));
let can_open = match kind {
- DelimiterKind::LinkStart => false,
+ // Anchors, like links, are resolved explicitly rather than by emphasis flanking.
+ DelimiterKind::LinkStart | DelimiterKind::HtmlAnchorStart => false,
DelimiterKind::Asterisk => left_flanking,
DelimiterKind::Underscore => {
left_flanking && (!right_flanking || preceded_by_punctuation)
@@ -1761,7 +1916,7 @@ impl Delimiter {
};
let can_close = match kind {
- DelimiterKind::LinkStart => false,
+ DelimiterKind::LinkStart | DelimiterKind::HtmlAnchorStart => false,
DelimiterKind::Asterisk => right_flanking,
DelimiterKind::Underscore => {
right_flanking && (!left_flanking || followed_by_punctuation)
@@ -1778,6 +1933,7 @@ impl Delimiter {
can_open,
active: true,
node_index,
+ anchor_href: None,
}
}
@@ -1819,6 +1975,9 @@ enum DelimiterKind {
LinkStart,
Strikethrough,
UnderlineStart,
+ /// The opening `` of a raw HTML anchor. Resolved explicitly when `` is
+ /// encountered (like `LinkStart`), so it never participates in emphasis flanking.
+ HtmlAnchorStart,
}
impl DelimiterKind {
@@ -1832,6 +1991,7 @@ impl DelimiterKind {
// tildes do not create strikethrough.
DelimiterKind::Strikethrough => count <= 2,
DelimiterKind::UnderlineStart => count == 1,
+ DelimiterKind::HtmlAnchorStart => count == 1,
}
}
@@ -1842,6 +2002,10 @@ impl DelimiterKind {
DelimiterKind::LinkStart => "[",
DelimiterKind::Strikethrough => "~",
DelimiterKind::UnderlineStart => "",
+ // An anchor's opening tag is variable-length, so its literal fallback text is stored
+ // on the placeholder fragment at parse time rather than reconstructed from a static
+ // string. `to_text`/`truncate_delimiters` are never invoked for this kind.
+ DelimiterKind::HtmlAnchorStart => "",
}
}
}
diff --git a/crates/markdown_parser/src/markdown_parser_tests.rs b/crates/markdown_parser/src/markdown_parser_tests.rs
index d347c01c981..13c8316b42a 100644
--- a/crates/markdown_parser/src/markdown_parser_tests.rs
+++ b/crates/markdown_parser/src/markdown_parser_tests.rs
@@ -1981,6 +1981,112 @@ fn test_parse_empty_underline() {
)
}
+#[test]
+fn test_parse_html_anchor_external_href() {
+ // Invariant 1: `` produces a Hyperlink::Url identical in shape to a markdown link.
+ assert_eq!(
+ parse_all("Visit Warp", parse_inline),
+ vec![FormattedTextFragment::hyperlink(
+ "Visit Warp",
+ "https://warp.dev"
+ )]
+ );
+ // The markdown-native equivalent produces the same fragment.
+ assert_eq!(
+ parse_all("Visit Warp", parse_inline),
+ parse_all("[Visit Warp](https://warp.dev)", parse_inline),
+ );
+}
+
+#[test]
+fn test_parse_html_anchor_fragment_href() {
+ // Invariant 2: an in-page `#fragment` href parses as Hyperlink::Url("#…"); resolution is
+ // tested separately in the editor model.
+ assert_eq!(
+ parse_all(
+ "Jump to Target Section",
+ parse_inline
+ ),
+ vec![FormattedTextFragment::hyperlink(
+ "Jump to Target Section",
+ "#target-section"
+ )]
+ );
+}
+
+#[test]
+fn test_parse_html_anchor_single_quotes() {
+ assert_eq!(
+ parse_all("Warp", parse_inline),
+ vec![FormattedTextFragment::hyperlink("Warp", "https://warp.dev")]
+ );
+}
+
+#[test]
+fn test_parse_html_anchor_ignores_other_attributes() {
+ // Invariant 8: attributes beyond `href` are parsed-but-ignored — no effect on output.
+ assert_eq!(
+ parse_all(
+ "Warp",
+ parse_inline
+ ),
+ vec![FormattedTextFragment::hyperlink("Warp", "https://warp.dev")]
+ );
+ // `href` need not be the first attribute.
+ assert_eq!(
+ parse_all(
+ "Warp",
+ parse_inline
+ ),
+ vec![FormattedTextFragment::hyperlink("Warp", "https://warp.dev")]
+ );
+}
+
+#[test]
+fn test_parse_html_anchor_inline_styling_in_body() {
+ // Invariant 9: inline styling inside the anchor body is preserved, and each styled fragment
+ // still carries the hyperlink.
+ let link = |text: &str| FormattedTextFragment {
+ text: text.to_string(),
+ styles: FormattedTextStyles {
+ hyperlink: Some(Hyperlink::Url("https://warp.dev".to_string())),
+ ..Default::default()
+ },
+ };
+ let mut bold_link = link("bold");
+ bold_link.styles.weight = Some(CustomWeight::Bold);
+ assert_eq!(
+ parse_all(
+ "a **bold** link",
+ parse_inline
+ ),
+ vec![link("a "), bold_link, link(" link")]
+ );
+}
+
+#[test]
+fn test_parse_html_anchor_unterminated_falls_back_to_text() {
+ // Invariant 10: a missing closing `` degrades to literal text without swallowing the
+ // rest of the paragraph.
+ assert_eq!(
+ parse_all("no close", parse_inline),
+ vec![FormattedTextFragment::plain_text(
+ "no close"
+ )]
+ );
+ // A stray closing tag with no opener is literal text too.
+ assert_eq!(
+ parse_all("just here", parse_inline),
+ vec![FormattedTextFragment::plain_text("just here")]
+ );
+ // An `` with no href at all is not a phase-1 hyperlink — it stays literal (its ``
+ // handling is a phase-2 concern).
+ assert_eq!(
+ parse_all("", parse_inline),
+ vec![FormattedTextFragment::plain_text("")]
+ );
+}
+
#[test]
fn test_unordered_list_indentation_level_relative() {
// Test that both 2-space and 4-space relative indentation produce the same structure
diff --git a/specs/GH13725/product.md b/specs/GH13725/product.md
new file mode 100644
index 00000000000..5a3d1869528
--- /dev/null
+++ b/specs/GH13725/product.md
@@ -0,0 +1,236 @@
+# PRODUCT.md — Markdown viewer: ``/`` anchor links
+
+Issue: https://github.com/warpdotdev/warp/issues/13725
+
+Split from: #13652 (bulk raw-HTML-subset request, closed in favor of per-feature issues).
+Sibling specs in the same split: `` sizing (#13721), raw HTML tables (#13726,
+`specs/GH13652/tables/`), ``/`` (#10259), ` ` (#13732), ``
+(#13733), ``/`` (#13734), `align` (#13735), ``/`` (#13736).
+
+## Summary
+
+Hand-built READMEs and docs commonly pair `` (or `name="section"`) anchor
+targets with `[Jump to Section](#section)` or `` links, either as a
+manual table of contents or as inline cross-references. Warp's Markdown viewer supports
+neither half of this pattern today: raw ``/`` tags render as literal text
+(there is no HTML-tag token in the inline parser at all), and even the markdown-native
+`[text](#fragment)` form — which **does** parse today as a link whose target is the literal
+string `#fragment` — has nothing to resolve that fragment against, because no heading or
+anchor in the document carries an id/slug of any kind.
+
+This request is therefore two related but separable capabilities:
+
+1. **Raw HTML anchor tags.** Recognize inline `…` as a hyperlink (reusing
+ the existing `Hyperlink` link styling) and ``/`` as a named anchor
+ target attached to the surrounding content.
+2. **Fragment resolution + scroll-to.** Give every heading an implicit anchor (a slug
+ derived from its text, GitHub-style), let an explicit `` register an anchor too,
+ and make any link whose target is `#fragment` — whether it came from `` or from
+ markdown-native `[text](#fragment)` — scroll the viewer to the matching anchor instead
+ of falling through to plain-URL handling.
+
+Capability (2) is the harder half and is also what markdown-native links need — a
+`[text](#fragment)` link already parses correctly and gets no benefit from HTML anchor
+parsing at all. The issue explicitly frames working in-document anchor links as a
+prerequisite for any future table-of-contents feature (#13083, #4720).
+
+Figma: none provided.
+
+## Goals / Non-goals
+
+In scope:
+
+- Parse an inline `link text` as a hyperlink with the given text, styled
+ and clickable exactly like a markdown `[link text](…)` — including external URLs
+ (`https://…`) and in-page fragments (`#target`).
+- Parse `` and `` (empty or self-closing, the common
+ hand-authored form) as a named anchor target at that point in the document. **As shipped,
+ the tag itself still renders as visible literal text** (e.g. `` appears
+ inline in the document) rather than rendering nothing the way GitHub/browsers do — see the
+ note under invariant 5 and #13982 for why, and for the deferred follow-up that would hide
+ it.
+- Give every heading (`#`…`######`) an **implicit** anchor slug derived from its rendered
+ text, so `[Jump to Target Section](#target-section)` works against ordinary headings with
+ zero authoring effort — the common case the issue's test document exercises.
+- Resolve a `#fragment` hyperlink click (from either an `` tag or a markdown
+ `[text](#fragment)` link) against the set of anchors in the current document — explicit
+ ``/`` targets and implicit heading slugs — and scroll the viewer so the
+ target is visible, instead of the current behavior (treated as an opaque URL).
+- **Cross-document fragment links** — a link whose target combines a relative file path and
+ a fragment, e.g. `[text](other-file.md#section)`. Clicking it opens (or, if already open,
+ focuses) that file's Markdown-viewer tab and scrolls it to the matching anchor once the
+ document has loaded. A relative file link *without* a fragment already opens the target
+ today; this in-scope item is specifically the fragment half — carrying the `#section`
+ through the file-open flow and scrolling after load. **Delivered in this PR** alongside
+ phase 1 (see phasing), because it builds on the same-document slug resolver rather than
+ replacing it, and because implementation surfaced three latent resolution defects
+ (documented in the tech spec) that had to be repaired for even a fragment-less bare
+ `README.md` link to open reliably.
+- A `#fragment` link with no matching anchor in the document degrades gracefully: it
+ remains a normally-styled, clickable-looking link, but clicking it is a no-op (no
+ navigation, no error, no crash) rather than attempting to open `#fragment` as a URL.
+- `` attributes beyond `href` — `title`, `target`, `rel`, `class`, etc. — are
+ parsed-but-ignored: they don't break parsing, and they don't do anything (no `target`
+ window semantics, no HTML tooltip from `title`).
+
+Out of scope (explicit non-goals):
+
+- **Following a cross-document fragment link into a non-Markdown target, or into an
+ external editor.** The cross-document scroll-to-anchor (in-scope above, phase 3) applies
+ only when the target file opens in Warp's Markdown viewer. If the user's Markdown Viewer
+ preference is off — so the file opens in the code editor or an external app — the
+ fragment is dropped and only the file opens, matching how a plain relative file link
+ behaves today. Bridging a `#slug` anchor to a code-editor line is a separate concern.
+- **Cross-document anchor scroll in the terminal (TUI) Markdown renderer.** Same rationale
+ as the same-document TUI non-goal below — the TUI has no scroll model or click-to-open
+ path. `other-file.md#section` in the TUI renders as inert styled text.
+- **Slug-collision policy beyond "first wins."** If two headings produce the same slug
+ (e.g. two headings both literally titled "Overview"), only the first is addressable by
+ that slug — matching common Markdown-renderer behavior (GitHub disambiguates duplicates
+ by appending `-1`, `-2`, …; replicating that exact disambiguation scheme is left to the
+ tech spec's judgment, not mandated here).
+- **`` tags with both `href` and `id`/`name` on the same element.** The issue's test
+ case and the common real-world pattern always use them separately (`` as a bare
+ target, `` as the link). Supporting both roles on one tag is not required.
+- **Any other raw-HTML tag** (``, `