Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion app/src/notebooks/editor/model.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
use base64::{prelude::BASE64_STANDARD, Engine as _};
use std::{any::Any, borrow::Cow, collections::HashMap, ops::Range, time::Duration};
use std::{
any::Any,
borrow::Cow,
collections::{HashMap, HashSet},
ops::Range,
time::Duration,
};

use itertools::Itertools;
use lazy_static::lazy_static;
Expand Down Expand Up @@ -1257,6 +1263,108 @@ impl NotebooksEditorModel {
self.content.as_ref(app).link_url_at_offset(offset)
}

pub fn scroll_to_markdown_anchor(
&mut self,
anchor: &str,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(range) = self.markdown_anchor_target(anchor, ctx) else {
return false;
};

self.render_state.update(ctx, |render_state, _| {
render_state
.request_autoscroll_to(AutoScrollMode::PositionOffsetInViewportCenter(range.start));
});
true
}

fn markdown_anchor_target(&self, anchor: &str, ctx: &AppContext) -> Option<Range<CharOffset>> {
let anchor = Self::normalize_markdown_anchor(anchor)?;
let content = self.content.as_ref(ctx);
let mut seen_slugs = HashMap::<String, usize>::new();
let mut used_slugs = HashSet::<String>::new();

for outline in content.outline_blocks() {
if !matches!(
&outline.block_type,
BlockType::Text(BufferBlockStyle::Header { .. })
) {
continue;
}

let heading = content
.text_in_range(outline.start + 1..outline.end)
.into_string();
let slug = Self::markdown_anchor_slug(&heading);
if slug.is_empty() {
continue;
}

let unique_slug =
Self::unique_markdown_anchor_slug(&slug, &mut seen_slugs, &mut used_slugs);

if unique_slug == anchor {
return Some(outline.start..outline.end);
}
}

None
}

fn unique_markdown_anchor_slug(
slug: &str,
seen_slugs: &mut HashMap<String, usize>,
used_slugs: &mut HashSet<String>,
) -> String {
let suffix = seen_slugs.entry(slug.to_string()).or_insert(0);
let mut candidate = if *suffix == 0 {
slug.to_string()
} else {
format!("{slug}-{suffix}")
};

while used_slugs.contains(&candidate) {
*suffix += 1;
candidate = format!("{slug}-{suffix}");
}

*suffix += 1;
used_slugs.insert(candidate.clone());
candidate
}

fn normalize_markdown_anchor(anchor: &str) -> Option<String> {
let fragment = anchor.strip_prefix('#')?;
if fragment.is_empty() {
return None;
}

let decoded = urlencoding::decode(fragment).ok()?;
let slug = Self::markdown_anchor_slug(decoded.as_ref());
(!slug.is_empty()).then_some(slug)
}

fn markdown_anchor_slug(text: &str) -> String {
let mut slug = String::new();
let mut previous_hyphen = false;

for ch in text.trim().chars().flat_map(|ch| ch.to_lowercase()) {
if ch.is_alphanumeric() || ch == '_' {
slug.push(ch);
previous_hyphen = false;
} else if (ch.is_whitespace() || ch == '-') && !previous_hyphen && !slug.is_empty() {
Comment thread
haikomatt marked this conversation as resolved.
Outdated
slug.push('-');
previous_hyphen = true;
}
}

if previous_hyphen {
slug.pop();
}
slug
}

/// Whether or not there's an active command block selection.
pub fn has_command_selection(&self, ctx: &AppContext) -> bool {
self.child_models
Expand Down
85 changes: 85 additions & 0 deletions app/src/notebooks/editor/model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,91 @@ fn test_inline_markdown_double_leading_underscore_not_italic() {
})
}

#[test]
fn test_markdown_anchor_target_matches_heading() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("- [Goal](#goal)\n\n## Goal\nBody", &mut app, true);

editor.read(&app, |editor, ctx| {
let range = editor
.markdown_anchor_target("#goal", ctx)
.expect("Anchor should match heading");
let heading = editor
.content
.as_ref(ctx)
.text_in_range(range.start + 1..range.end)
.into_string();

assert_eq!(heading, "Goal");
});
})
}

#[test]
fn test_markdown_anchor_target_normalizes_heading_text() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## My **Bold** Goal!\nBody", &mut app, true);

editor.read(&app, |editor, ctx| {
let range = editor
.markdown_anchor_target("#my-bold-goal", ctx)
.expect("Anchor should match normalized heading");
let heading = editor
.content
.as_ref(ctx)
.text_in_range(range.start + 1..range.end)
.into_string();

assert_eq!(heading, "My Bold Goal!");
});
})
}

#[test]
fn test_markdown_anchor_target_handles_duplicate_headings() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## Goal\nFirst\n\n## Goal\nSecond", &mut app, true);

editor.read(&app, |editor, ctx| {
let first = editor
.markdown_anchor_target("#goal", ctx)
.expect("First anchor should match");
let second = editor
.markdown_anchor_target("#goal-1", ctx)
.expect("Duplicate anchor should match");

assert!(second.start > first.start);
assert!(editor.markdown_anchor_target("#goal-2", ctx).is_none());
});
})
}

#[test]
fn test_markdown_anchor_target_avoids_slug_collisions() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown(
"## Goal\nFirst\n\n## Goal\nSecond\n\n## Goal-1\nNatural suffix",
&mut app,
true,
);

editor.read(&app, |editor, ctx| {
let duplicate = editor
.markdown_anchor_target("#goal-1", ctx)
.expect("Duplicate anchor should match");
let natural_suffix = editor
.markdown_anchor_target("#goal-1-1", ctx)
.expect("Colliding natural suffix should remain reachable");

assert!(natural_suffix.start > duplicate.start);
});
})
}

#[test]
fn test_cursor_bias_editing() {
App::test((), |mut app| async move {
Expand Down
10 changes: 10 additions & 0 deletions app/src/notebooks/editor/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1880,6 +1880,16 @@ impl RichTextEditorView {
};

if let Some(url) = url {
if url.starts_with('#')
&& (cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable))
{
self.open_link = None;
self.model.update(ctx, |model, ctx| {
model.scroll_to_markdown_anchor(&url, ctx);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 [SUGGESTION] Only consume anchor clicks after scroll_to_markdown_anchor succeeds; otherwise missing or malformed fragments are swallowed and never reach existing link resolution.

});
ctx.notify();
return;
}
// In read-only comment chips (Selectable), open the link directly on
// click instead of showing a tooltip.
if cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable) {
Expand Down
75 changes: 75 additions & 0 deletions app/src/notebooks/editor/view_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ async fn reset_editor_with_markdown(
.await;
}

fn link_offset(
editor: &RichTextEditorView,
link_url: &str,
ctx: &warpui::AppContext,
) -> CharOffset {
let max_offset = editor.markdown(ctx).chars().count() + 10;
(0..=max_offset)
.map(CharOffset::from)
.find(|offset| {
editor
.model
.as_ref(ctx)
.link_url_at(*offset, ctx)
.as_deref()
== Some(link_url)
})
.expect("Expected link URL to exist in editor")
}

fn rendered_mermaid_block_range(
editor: &RichTextEditorView,
ctx: &warpui::AppContext,
Expand Down Expand Up @@ -522,6 +541,62 @@ fn test_link_editing() {
});
}

#[test]
fn test_editable_markdown_anchor_click_opens_link_tooltip() {
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;

let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#goal", ctx));
editor_view.update(&mut app, |editor, ctx| {
editor.handle_action(
&EditorViewAction::MaybeOpenFileOrUrl {
offset,
link_in_text: None,
cmd: false,
},
ctx,
);
});

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);
});
});
}

#[test]
fn test_cmd_click_markdown_anchor_navigates_without_link_tooltip() {
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;

let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#goal", ctx));
editor_view.update(&mut app, |editor, ctx| {
editor.handle_action(
&EditorViewAction::MaybeOpenFileOrUrl {
offset,
link_in_text: None,
cmd: true,
},
ctx,
);
});

editor_view.read(&app, |editor, _ctx| {
assert!(
editor.open_link.is_none(),
"Cmd-click anchor navigation should not show the link tooltip"
);
});
});
}

#[test]
fn test_run_command_from_text_selection() {
// This tests that, starting from a text selection, we can still run a command.
Expand Down
6 changes: 6 additions & 0 deletions crates/editor/test_fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ The `images/` directory contains sample images and a test markdown file (`image_
- Empty alt text

To test image rendering, open `images/image_test.md` in Warp.

## ToC Anchors

`toc_anchor_test.md` covers manual validation for Markdown table-of-contents fragment links, including punctuation normalization, duplicate headings, natural suffix collisions, and long-document scrolling.

To test anchor navigation, open `toc_anchor_test.md` in Warp's Markdown viewer and click the table-of-contents links.
61 changes: 61 additions & 0 deletions crates/editor/test_fixtures/toc_anchor_test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Markdown ToC Anchor Manual Test

Use this file in a Warp notebook/editor test flow to verify Markdown fragment links.

## Table of contents

- [Basic heading](#basic-heading)
- [Heading with punctuation](#heading-with-punctuation)
- [Duplicate heading](#duplicate-heading)
- [Duplicate heading again](#duplicate-heading-1)
- [Natural suffix heading](#duplicate-heading-2)
- [Mixed case and symbols](#mixed-case--symbols)
- [Bottom target](#bottom-target)

## Basic heading

Expected: clicking `Basic heading` in selectable/read-only mode scrolls here. In editable mode, a normal click should show the link tooltip/editor instead of immediately scrolling.

## Heading with punctuation!

Expected: punctuation is normalized out, so `#heading-with-punctuation` scrolls here.

## Duplicate heading

Expected: the first duplicate target resolves to `#duplicate-heading`.

## Duplicate heading

Expected: the second duplicate target resolves to `#duplicate-heading-1`.

## Duplicate heading-1

Expected: this natural suffix heading should not steal `#duplicate-heading-1`; it should resolve as `#duplicate-heading-2`.

## Mixed CASE & Symbols

Expected: mixed case and symbols normalize to `#mixed-case--symbols`.

## Scroll padding section 1

This filler makes scrolling visible.

## Scroll padding section 2

This filler makes scrolling visible.

## Scroll padding section 3

This filler makes scrolling visible.

## Scroll padding section 4

This filler makes scrolling visible.

## Scroll padding section 5

This filler makes scrolling visible.

## Bottom target

Expected: clicking `Bottom target` from the TOC scrolls near the bottom of the document.