diff --git a/app/src/notebooks/editor/model.rs b/app/src/notebooks/editor/model.rs index e7aa9b8ac..3c5e00ac4 100644 --- a/app/src/notebooks/editor/model.rs +++ b/app/src/notebooks/editor/model.rs @@ -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; @@ -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, + ) -> 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> { + let anchor = Self::normalize_markdown_anchor(anchor)?; + let content = self.content.as_ref(ctx); + let mut seen_slugs = HashMap::::new(); + let mut used_slugs = HashSet::::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, + used_slugs: &mut HashSet, + ) -> 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 { + 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 diff --git a/app/src/notebooks/editor/model_tests.rs b/app/src/notebooks/editor/model_tests.rs index 40ffc655b..c328ef2c7 100644 --- a/app/src/notebooks/editor/model_tests.rs +++ b/app/src/notebooks/editor/model_tests.rs @@ -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 { diff --git a/app/src/notebooks/editor/view.rs b/app/src/notebooks/editor/view.rs index 1cb918615..f31a83d8b 100644 --- a/app/src/notebooks/editor/view.rs +++ b/app/src/notebooks/editor/view.rs @@ -1880,6 +1880,18 @@ 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_markdown_anchor(&url, ctx)); + if scrolled { + self.open_link = None; + 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) { diff --git a/app/src/notebooks/editor/view_tests.rs b/app/src/notebooks/editor/view_tests.rs index 2a0c00061..9a458572e 100644 --- a/app/src/notebooks/editor/view_tests.rs +++ b/app/src/notebooks/editor/view_tests.rs @@ -1,7 +1,9 @@ use crate::features::FeatureFlag; use async_channel::TryRecvError; -use std::sync::Arc; +use parking_lot::Mutex; +use std::{path::PathBuf, sync::Arc}; use string_offset::CharOffset; +use tempfile::tempdir; use warp_editor::render::{ element::RichTextAction, model::{HitTestBlockType, Location, RenderEvent}, @@ -21,7 +23,7 @@ use crate::notebooks::editor::keys::NotebookKeybindings; use crate::notebooks::editor::link_editor::LinkEditorAction; use crate::notebooks::editor::model::NotebooksEditorModel; use crate::notebooks::editor::rich_text_styles; -use crate::notebooks::link::{NotebookLinks, SessionSource}; +use crate::notebooks::link::{LinkEvent, NotebookLinks, SessionSource}; use crate::server::server_api::team::MockTeamClient; use crate::server::server_api::workspace::MockWorkspaceClient; @@ -30,6 +32,8 @@ use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::auth::AuthStateProvider; use crate::terminal::keys::TerminalKeybindings; +use crate::terminal::{model::session::Session, shell::ShellType, ShellLaunchData}; +use crate::test_util::assert_eventually; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspace::ActiveSession; use crate::UserWorkspaces; @@ -134,6 +138,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, @@ -522,6 +545,128 @@ 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_cmd_click_missing_markdown_anchor_falls_back_to_link_resolution() { + App::test((), |mut app| async move { + let (window_id, editor_view, _) = initialize_editor(&mut app); + let base = tempdir().expect("Expected temp dir"); + 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( + ShellLaunchData::Executable { + executable_path: PathBuf::from("/bin/bash"), + shell_type: ShellType::Bash, + }, + )); + + ActiveSession::handle(&app).update(&mut app, |active_session, ctx| { + active_session.set_session_for_test( + window_id, + session.clone(), + Some(base.path()), + None, + ctx, + ); + }); + + reset_editor_with_markdown( + &mut app, + &editor_view, + "- [Missing](#missing.png)\n\n## Goal", + ) + .await; + + let events = Arc::new(Mutex::new(Vec::::new())); + let links = editor_view.read(&app, |editor, _ctx| editor.links.clone()); + { + let events = events.clone(); + app.update(|ctx| { + ctx.subscribe_to_model(&links, move |_, event, _| { + events.lock().push(event.clone()); + }) + }); + } + + let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#missing.png", ctx)); + editor_view.update(&mut app, |editor, ctx| { + editor.handle_action( + &EditorViewAction::MaybeOpenFileOrUrl { + offset, + link_in_text: None, + cmd: true, + }, + ctx, + ); + }); + + 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() + ); + }); +} #[test] fn test_run_command_from_text_selection() { // This tests that, starting from a text selection, we can still run a command. diff --git a/crates/editor/test_fixtures/README.md b/crates/editor/test_fixtures/README.md index 247bdd6c2..e46635c91 100644 --- a/crates/editor/test_fixtures/README.md +++ b/crates/editor/test_fixtures/README.md @@ -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. diff --git a/crates/editor/test_fixtures/toc_anchor_test.md b/crates/editor/test_fixtures/toc_anchor_test.md new file mode 100644 index 000000000..a89c5da97 --- /dev/null +++ b/crates/editor/test_fixtures/toc_anchor_test.md @@ -0,0 +1,71 @@ +# 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) +- [Symbol-separated heading](#a--b) +- [Space-separated heading](#a-b) +- [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`. + +## A & B + +Expected: punctuation is dropped after spaces become hyphens, so this resolves to `#a--b`. + +## A B + +Expected: this resolves to `#a-b`, remaining distinct from `#a--b`. + +## 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.