Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions app/src/notebooks/editor/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1880,6 +1880,14 @@ impl RichTextEditorView {
};

if let Some(url) = url {
if url.starts_with('#') {
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;
}
Comment thread
haikomatt marked this conversation as resolved.
Outdated
// 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