From bc5e5048062ac21636cf6f19daf06d4d33b85766 Mon Sep 17 00:00:00 2001 From: haikomatt Date: Tue, 5 May 2026 12:33:02 +0100 Subject: [PATCH 1/5] Fix Markdown anchor navigation in notebooks Resolve rendered Markdown fragment links inside notebook editors by matching URL fragments against Markdown heading slugs and scrolling to the target heading. Add regression coverage for normalized and duplicate heading anchors. Co-Authored-By: Oz --- app/src/notebooks/editor/model.rs | 84 +++++++++++++++++++++++++ app/src/notebooks/editor/model_tests.rs | 62 ++++++++++++++++++ app/src/notebooks/editor/view.rs | 8 +++ 3 files changed, 154 insertions(+) diff --git a/app/src/notebooks/editor/model.rs b/app/src/notebooks/editor/model.rs index e7aa9b8ac..6083b4bff 100644 --- a/app/src/notebooks/editor/model.rs +++ b/app/src/notebooks/editor/model.rs @@ -1257,6 +1257,90 @@ 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(); + + 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 count = seen_slugs.entry(slug.clone()).or_insert(0); + let unique_slug = if *count == 0 { + slug + } else { + format!("{slug}-{count}") + }; + *count += 1; + + if unique_slug == anchor { + return Some(outline.start..outline.end); + } + } + + None + } + + 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(); + 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() { + 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 diff --git a/app/src/notebooks/editor/model_tests.rs b/app/src/notebooks/editor/model_tests.rs index 40ffc655b..0c9ebcf7b 100644 --- a/app/src/notebooks/editor/model_tests.rs +++ b/app/src/notebooks/editor/model_tests.rs @@ -498,6 +498,68 @@ 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_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..9d43b584d 100644 --- a/app/src/notebooks/editor/view.rs +++ b/app/src/notebooks/editor/view.rs @@ -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); + }); + 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) { From 904f92db927031055c557a8007602b06219dc94a Mon Sep 17 00:00:00 2001 From: haikomatt Date: Tue, 5 May 2026 14:30:35 +0100 Subject: [PATCH 2/5] Avoid Markdown anchor slug collisions Co-Authored-By: Oz --- app/src/notebooks/editor/model.rs | 40 ++++++++++++++++++++----- app/src/notebooks/editor/model_tests.rs | 23 ++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/app/src/notebooks/editor/model.rs b/app/src/notebooks/editor/model.rs index 6083b4bff..0155997d3 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; @@ -1277,6 +1283,7 @@ impl NotebooksEditorModel { 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!( @@ -1294,13 +1301,8 @@ impl NotebooksEditorModel { continue; } - let count = seen_slugs.entry(slug.clone()).or_insert(0); - let unique_slug = if *count == 0 { - slug - } else { - format!("{slug}-{count}") - }; - *count += 1; + 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); @@ -1310,6 +1312,28 @@ impl NotebooksEditorModel { 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() { diff --git a/app/src/notebooks/editor/model_tests.rs b/app/src/notebooks/editor/model_tests.rs index 0c9ebcf7b..9d58560aa 100644 --- a/app/src/notebooks/editor/model_tests.rs +++ b/app/src/notebooks/editor/model_tests.rs @@ -560,6 +560,29 @@ fn test_markdown_anchor_target_handles_duplicate_headings() { }) } +#[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 { From 4ad1a7c1442d94d70ea408cf6ec0e420abda5959 Mon Sep 17 00:00:00 2001 From: haikomatt Date: Tue, 5 May 2026 15:51:14 +0100 Subject: [PATCH 3/5] Preserve editable tooltip for Markdown anchor links This updates the Markdown anchor navigation logic to only intercept fragment links when cmd/ctrl-clicking or when the editor is in a read-only/selectable mode. Normal clicks in editable mode now properly fall through to show the link editing tooltip. A manual test fixture for anchor links was also added. Co-Authored-By: Oz --- app/src/notebooks/editor/view.rs | 4 +- app/src/notebooks/editor/view_tests.rs | 75 +++++++++++++++++++ crates/editor/test_fixtures/README.md | 6 ++ .../editor/test_fixtures/toc_anchor_test.md | 61 +++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 crates/editor/test_fixtures/toc_anchor_test.md diff --git a/app/src/notebooks/editor/view.rs b/app/src/notebooks/editor/view.rs index 9d43b584d..e559c4b27 100644 --- a/app/src/notebooks/editor/view.rs +++ b/app/src/notebooks/editor/view.rs @@ -1880,7 +1880,9 @@ impl RichTextEditorView { }; if let Some(url) = url { - if url.starts_with('#') { + 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); diff --git a/app/src/notebooks/editor/view_tests.rs b/app/src/notebooks/editor/view_tests.rs index 2a0c00061..c14608c68 100644 --- a/app/src/notebooks/editor/view_tests.rs +++ b/app/src/notebooks/editor/view_tests.rs @@ -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, @@ -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. diff --git a/crates/editor/test_fixtures/README.md b/crates/editor/test_fixtures/README.md index 247bdd6c2..7c1812367 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, 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..d3a899e2c --- /dev/null +++ b/crates/editor/test_fixtures/toc_anchor_test.md @@ -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. From 3513ce0a15401aefeef44c32a4adb102b89b9898 Mon Sep 17 00:00:00 2001 From: haikomatt Date: Tue, 5 May 2026 16:17:27 +0100 Subject: [PATCH 4/5] Preserve Markdown anchor separator runs Keep whitespace-generated hyphens distinct when normalizing Markdown heading anchors so symbol-separated headings like "A & B" do not collide with space-separated headings like "A B". Add regression coverage and update the manual ToC anchor fixture for the separator-run collision case. Co-Authored-By: Oz --- app/src/notebooks/editor/model.rs | 14 ++++---------- app/src/notebooks/editor/model_tests.rs | 19 +++++++++++++++++++ crates/editor/test_fixtures/README.md | 2 +- .../editor/test_fixtures/toc_anchor_test.md | 10 ++++++++++ 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/src/notebooks/editor/model.rs b/app/src/notebooks/editor/model.rs index 0155997d3..3c5e00ac4 100644 --- a/app/src/notebooks/editor/model.rs +++ b/app/src/notebooks/editor/model.rs @@ -1347,21 +1347,15 @@ impl NotebooksEditorModel { 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() { + for ch in text.trim().to_lowercase().chars() { + if ch.is_whitespace() { slug.push('-'); - previous_hyphen = true; + } else if ch.is_alphanumeric() || ch == '_' || ch == '-' { + slug.push(ch); } } - if previous_hyphen { - slug.pop(); - } slug } diff --git a/app/src/notebooks/editor/model_tests.rs b/app/src/notebooks/editor/model_tests.rs index 9d58560aa..c328ef2c7 100644 --- a/app/src/notebooks/editor/model_tests.rs +++ b/app/src/notebooks/editor/model_tests.rs @@ -540,6 +540,25 @@ fn test_markdown_anchor_target_normalizes_heading_text() { }) } +#[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 { diff --git a/crates/editor/test_fixtures/README.md b/crates/editor/test_fixtures/README.md index 7c1812367..e46635c91 100644 --- a/crates/editor/test_fixtures/README.md +++ b/crates/editor/test_fixtures/README.md @@ -17,6 +17,6 @@ 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. +`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 index d3a899e2c..a89c5da97 100644 --- a/crates/editor/test_fixtures/toc_anchor_test.md +++ b/crates/editor/test_fixtures/toc_anchor_test.md @@ -10,6 +10,8 @@ Use this file in a Warp notebook/editor test flow to verify Markdown fragment li - [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 @@ -36,6 +38,14 @@ Expected: this natural suffix heading should not steal `#duplicate-heading-1`; i 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. From 7ea7982a7fb65e85b0512871a27174a8ec2a14b5 Mon Sep 17 00:00:00 2001 From: haikomatt Date: Tue, 5 May 2026 16:48:56 +0100 Subject: [PATCH 5/5] Fall back when Markdown anchors are missing Co-Authored-By: Oz --- app/src/notebooks/editor/view.rs | 14 ++--- app/src/notebooks/editor/view_tests.rs | 74 +++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/app/src/notebooks/editor/view.rs b/app/src/notebooks/editor/view.rs index e559c4b27..f31a83d8b 100644 --- a/app/src/notebooks/editor/view.rs +++ b/app/src/notebooks/editor/view.rs @@ -1883,12 +1883,14 @@ impl RichTextEditorView { 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); - }); - ctx.notify(); - return; + 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. diff --git a/app/src/notebooks/editor/view_tests.rs b/app/src/notebooks/editor/view_tests.rs index c14608c68..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; @@ -597,6 +601,72 @@ fn test_cmd_click_markdown_anchor_navigates_without_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.