Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
104 changes: 103 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,102 @@ 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();

for ch in text.trim().to_lowercase().chars() {
if ch.is_whitespace() {
slug.push('-');
} else if ch.is_alphanumeric() || ch == '_' || ch == '-' {
slug.push(ch);
}
}

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
104 changes: 104 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,110 @@ 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_preserves_separator_runs() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## A & B\nFirst\n\n## A B\nSecond", &mut app, true);

editor.read(&app, |editor, ctx| {
let symbol_separated = editor
.markdown_anchor_target("#a--b", ctx)
.expect("Symbol-separated heading should match double hyphen slug");
let space_separated = editor
.markdown_anchor_target("#a-b", ctx)
.expect("Space-separated heading should match single hyphen slug");

assert!(space_separated.start > symbol_separated.start);
});
})
}

#[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, separator-run 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.
Loading