From 1102219f812293a09da88eff1df95fbcedde6b09 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 28 Jul 2026 19:26:46 +0530 Subject: [PATCH] markdown_preview: Open Markdown links in preview and scroll to linked headings and positions (#61784) Closes #60985 Clicking a link to a Markdown file from a Markdown preview now opens the target in a preview instead of the raw editor buffer. `alt`-click opens the raw source editor instead. - If there exists existing preview of that file it is reused. A link back to the file being previewed navigates in place. - `file.md#heading-slug` scrolls the preview to the linked heading. - `file.md:42:5`, `file.md#L42` and `file.md#L42C5` open the preview scrolled to that position with the cursor placed there. Non-Markdown targets keep the existing behavior from #60864 (raw editor at the position). Added tests covering these and more cases. Release Notes: - Improved Markdown preview links: links to Markdown files now open in a preview (scrolled to the linked heading or position) instead of the raw file, and `alt`-click opens the raw source instead. - Fixed GitHub-style `#LC` link fragments not placing the cursor at the linked position. --- crates/markdown/src/markdown.rs | 57 +- .../src/markdown_preview_view.rs | 622 ++++++++++++++++-- crates/project_panel/src/project_panel.rs | 13 +- crates/util/src/markdown.rs | 12 +- 4 files changed, 633 insertions(+), 71 deletions(-) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 98443eba69..25dcd06528 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -395,6 +395,8 @@ pub struct Markdown { pressed_link: Option, pressed_footnote_ref: Option, autoscroll_request: Option, + pending_heading_scroll: Option, + pending_autoscroll: Option, active_root_block: Option, parsed_markdown: ParsedMarkdown, images_by_source_offset: HashMap>, @@ -588,6 +590,8 @@ impl Markdown { pressed_link: None, pressed_footnote_ref: None, autoscroll_request: None, + pending_heading_scroll: None, + pending_autoscroll: None, active_root_block: None, should_reparse: false, images_by_source_offset: Default::default(), @@ -705,6 +709,14 @@ impl Markdown { self.pending_parse.is_some() } + pub fn scroll_to_heading_when_parsed(&mut self, slug: SharedString, cx: &mut Context) { + if self.pending_parse.is_some() || self.source.is_empty() { + self.pending_heading_scroll = Some(slug); + } else { + self.scroll_to_heading(&slug, cx); + } + } + pub fn scroll_to_heading(&mut self, slug: &str, cx: &mut Context) -> Option { if let Some(source_index) = self.parsed_markdown.heading_slugs.get(slug).copied() { self.autoscroll_request = Some(source_index); @@ -756,7 +768,11 @@ impl Markdown { source_index: usize, cx: &mut Context, ) { - self.autoscroll_request = Some(source_index); + if self.pending_parse.is_some() { + self.pending_autoscroll = Some(source_index); + } else { + self.autoscroll_request = Some(source_index); + } cx.refresh_windows(); } @@ -784,11 +800,24 @@ impl Markdown { pub fn reset(&mut self, source: SharedString, cx: &mut Context) { if &source == self.source() { + if self.pending_parse.is_none() { + if let Some(slug) = self.pending_heading_scroll.take() { + self.pending_autoscroll = None; + self.scroll_to_heading(&slug, cx); + } else if let Some(source_index) = self.pending_autoscroll.take() { + self.autoscroll_request = Some(source_index); + cx.refresh_windows(); + } + } return; } + if !self.source.is_empty() { + self.pending_heading_scroll = None; + } self.source = source; self.selection = Selection::default(); self.autoscroll_request = None; + self.pending_autoscroll = None; self.pending_parse = None; self.should_reparse = false; self.search_highlights.clear(); @@ -940,6 +969,8 @@ impl Markdown { if self.source.is_empty() { self.should_reparse = false; self.pending_parse.take(); + self.pending_heading_scroll = None; + self.pending_autoscroll = None; self.parsed_markdown = ParsedMarkdown { source: self.source.clone(), ..Default::default() @@ -1100,6 +1131,14 @@ impl Markdown { this.pending_parse.take(); if this.should_reparse { this.parse(cx); + } else if let Some(slug) = this.pending_heading_scroll.take() + && let Some(source_index) = + this.parsed_markdown.heading_slugs.get(&slug).copied() + { + this.pending_autoscroll = None; + this.autoscroll_request = Some(source_index); + } else if let Some(source_index) = this.pending_autoscroll.take() { + this.autoscroll_request = Some(source_index); } cx.notify(); cx.refresh_windows(); @@ -4027,17 +4066,15 @@ impl RenderedText { fn position_for_source_index(&self, source_index: usize) -> Option<(Point, Pixels)> { for line in self.lines.iter() { - let line_source_start = line.source_mappings.first().unwrap().source_index; - if source_index < line_source_start { - break; - } else if source_index > line.source_end { + if source_index > line.source_end { continue; - } else { - let line_height = line.layout.line_height(); - let rendered_index_within_line = line.rendered_index_for_source_index(source_index); - let position = line.layout.position_for_index(rendered_index_within_line)?; - return Some((position, line_height)); } + let line_source_start = line.source_mappings.first().unwrap().source_index; + let source_index = source_index.max(line_source_start); + let line_height = line.layout.line_height(); + let rendered_index_within_line = line.rendered_index_for_source_index(source_index); + let position = line.layout.position_for_index(rendered_index_within_line)?; + return Some((position, line_height)); } None } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index e2496ae6e4..be3a777ac6 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -15,7 +15,7 @@ use gpui::{ InteractiveElement, IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, SharedUri, Subscription, Task, WeakEntity, Window, point, px, }; -use language::LanguageRegistry; +use language::{Buffer, LanguageRegistry}; use markdown::{ CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, @@ -100,6 +100,11 @@ pub enum MarkdownPreviewEvent { SourceFileHandleChanged, } +enum PreviewLinkTarget { + Heading(SharedString), + Position { row: u32, column: u32 }, +} + impl MarkdownPreviewView { pub fn register(workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context) { workspace.register_action(move |workspace, _: &OpenPreview, window, cx| { @@ -195,30 +200,30 @@ impl MarkdownPreviewView { let target_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; pane.items_of_type::() .find(|view| { - let view_read = view.read(cx); // Only look for independent (Default mode) previews, not Follow previews. // Match by buffer entity rather than editor entity so the lookup survives // workspace restoration, where the preview's bound editor may differ from // the editor the user is currently invoking the action on even though both // wrap the same source buffer. - view_read.mode == MarkdownPreviewMode::Default - && view_read - .active_editor - .as_ref() - .is_some_and(|active_editor| { - active_editor - .editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .as_ref() - == Some(&target_buffer) - }) + view.read(cx).mode == MarkdownPreviewMode::Default + && view.read(cx).is_previewing(&target_buffer, cx) }) .and_then(|view| pane.index_for_item(&view)) } + fn is_previewing(&self, buffer: &Entity, cx: &App) -> bool { + self.active_editor.as_ref().is_some_and(|state| { + state + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .as_ref() + == Some(buffer) + }) + } + pub fn resolve_active_item_as_markdown_editor( workspace: &Workspace, cx: &mut Context, @@ -363,10 +368,14 @@ impl MarkdownPreviewView { } } - pub fn is_markdown_path(path: impl AsRef) -> bool { - path.as_ref().extension().is_some_and(|ext| { - ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown") - }) + pub fn is_markdown_path(path: impl AsRef, languages: &Arc) -> bool { + languages + .language_for_file_path(path.as_ref()) + .is_some_and(|language_id| { + languages + .available_language_for_name("Markdown") + .is_some_and(|markdown| markdown.id() == language_id) + }) } pub fn open_for_project_path( @@ -623,6 +632,47 @@ impl MarkdownPreviewView { }); } + fn navigate_to_link_target( + &mut self, + target: Option, + window: &mut Window, + cx: &mut Context, + ) { + match target { + None => {} + Some(PreviewLinkTarget::Heading(slug)) => { + self.markdown.update(cx, |markdown, cx| { + markdown.scroll_to_heading_when_parsed(slug, cx); + }); + } + Some(PreviewLinkTarget::Position { row, column }) => { + let Some(editor) = self + .active_editor + .as_ref() + .map(|state| state.editor.clone()) + else { + return; + }; + let Some(source_index) = + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .map(|buffer| { + let snapshot = buffer.read(cx).snapshot(); + let point = snapshot.point_from_external_input(row, column); + snapshot.point_to_offset(point) + }) + else { + return; + }; + Self::change_selection_to_source_index(&editor, source_index, false, window, cx); + self.sync_preview_to_source_index(source_index, true, cx); + } + } + } + fn change_selection_to_source_index( editor: &Entity, source_index: usize, @@ -1079,6 +1129,7 @@ fn handle_url_click( SharedString::from(path_part.to_string()), fragment.map(|fragment| SharedString::from(fragment.to_string())), base_directory, + Some(view.clone()), workspace, window, cx, @@ -1090,43 +1141,77 @@ fn open_preview_url( url: SharedString, fragment: Option, base_directory: Option, + source_view: Option>, workspace: &WeakEntity, window: &mut Window, cx: &mut App, ) { let decoded_path = urlencoding::decode(&url).unwrap_or_else(|_| Cow::Borrowed(&url)); - // Only `://` URLs skip position handling (a scheme check would misclassify `main.rs:2`); ambiguous links like `tel:123` are tried as files first, then fall back to the URL handler. - let path_with_position = if !decoded_path.contains("://") { - if PathWithPosition::parse_str(&decoded_path).row.is_some() { + // Only `://` URLs skip local handling (a scheme check would misclassify `main.rs:2`); ambiguous links like `tel:123` are tried as files first, then fall back to the URL handler. + if !decoded_path.contains("://") { + let parsed_path = PathWithPosition::parse_str(&decoded_path); + let fragment_position = fragment + .as_deref() + .and_then(|fragment| fragment.strip_prefix('L')) + .and_then(source_position_from_fragment); + + if !window.modifiers().alt + && workspace + .read_with(cx, |workspace, cx| { + MarkdownPreviewView::is_markdown_path( + &parsed_path.path, + workspace.project().read(cx).languages(), + ) + }) + .is_ok_and(|is_markdown| is_markdown) + { + let link_target = if let Some(row) = parsed_path.row { + Some(PreviewLinkTarget::Position { + row: row.saturating_sub(1), + column: parsed_path.column.unwrap_or(1).saturating_sub(1), + }) + } else if let Some((row, column)) = fragment_position { + Some(PreviewLinkTarget::Position { row, column }) + } else { + fragment.map(PreviewLinkTarget::Heading) + }; + open_markdown_link_in_preview( + parsed_path.path.to_string_lossy().into_owned(), + link_target, + decoded_path.into_owned(), + base_directory, + source_view, + workspace, + window, + cx, + ); + return; + } + + let path_with_position = if parsed_path.row.is_some() { Some(decoded_path.to_string()) } else { - fragment - .as_deref() - .and_then(|fragment| fragment.strip_prefix('L')) - .and_then(source_position_from_fragment) - .map(|(row, column)| { - if column == 0 { - format!("{decoded_path}:{}", row + 1) - } else { - format!("{decoded_path}:{}:{}", row + 1, column + 1) - } - }) - } - } else { - None - }; + fragment_position.map(|(row, column)| { + if column == 0 { + format!("{decoded_path}:{}", row + 1) + } else { + format!("{decoded_path}:{}:{}", row + 1, column + 1) + } + }) + }; - if let Some(path_with_position) = path_with_position { - open_preview_path_with_position( - path_with_position, - decoded_path.into_owned(), - base_directory, - workspace, - window, - cx, - ); - return; + if let Some(path_with_position) = path_with_position { + open_preview_path_with_position( + path_with_position, + decoded_path.into_owned(), + base_directory, + workspace, + window, + cx, + ); + return; + } } if let Some(workspace) = workspace.upgrade() { @@ -1138,6 +1223,110 @@ fn open_preview_url( } } +fn find_preview_for_buffer( + workspace: &Workspace, + buffer: &Entity, + cx: &App, +) -> Option> { + workspace + .items_of_type::(cx) + .find(|view| { + view.read(cx).mode == MarkdownPreviewMode::Default + && view.read(cx).is_previewing(buffer, cx) + }) +} + +fn open_markdown_link_in_preview( + path: String, + link_target: Option, + fallback_url: String, + base_directory: Option, + source_view: Option>, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + let open_target = resolve_open_target( + workspace, + PathMatching::Exact, + &path, + base_directory.as_deref(), + cx, + ); + let workspace = workspace.clone(); + let workspace_for_error = workspace.clone(); + let task = window.spawn(cx, async move |cx| { + let target_path = open_target + .await + .filter(|open_target| open_target.is_file()) + .map(|open_target| open_target.path().path.clone()); + let Some(abs_path) = target_path else { + workspace.update_in(cx, |workspace, window, cx| { + workspace.open_url_or_file(&fallback_url, base_directory.as_deref(), window, cx); + })?; + return anyhow::Ok(()); + }; + + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; + let (worktree, relative_path) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(abs_path, false, cx) + }) + .await + .context("resolving Markdown preview link worktree")?; + let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id, + path: relative_path, + }, + cx, + ) + }) + .await + .context("opening Markdown preview link buffer")?; + + workspace.update_in(cx, |workspace, window, cx| { + if let Some(source_view) = source_view.as_ref().and_then(|view| view.upgrade()) + && source_view.read(cx).is_previewing(&buffer, cx) + { + source_view.update(cx, |view, cx| { + view.navigate_to_link_target(link_target, window, cx); + }); + return; + } + + if let Some(existing_view) = find_preview_for_buffer(workspace, &buffer, cx) { + workspace.activate_item(&existing_view, true, true, window, cx); + existing_view.update(cx, |view, cx| { + view.navigate_to_link_target(link_target, window, cx); + }); + return; + } + + let editor = cx.new(|cx| { + Editor::for_buffer(buffer, Some(workspace.project().clone()), window, cx) + }); + let preview = MarkdownPreviewView::create_markdown_view(workspace, editor, window, cx); + let pane = source_view + .as_ref() + .and_then(|view| view.upgrade()) + .and_then(|view| workspace.pane_for(&view)) + .unwrap_or_else(|| workspace.active_pane().clone()); + pane.update(cx, |pane, cx| { + pane.add_item(Box::new(preview.clone()), true, true, None, window, cx); + }); + preview.update(cx, |view, cx| { + view.navigate_to_link_target(link_target, window, cx); + }); + })?; + anyhow::Ok(()) + }); + task.detach_and_notify_err(workspace_for_error, window, cx); +} + fn open_preview_path_with_position( path_with_position: String, fallback_path: String, @@ -1793,7 +1982,9 @@ mod tests { use buffer_diff::BufferDiff; use editor::Editor; use fs::FakeFs; - use gpui::{AppContext as _, Entity, Focusable as _, TestAppContext, WindowHandle}; + use gpui::{ + AppContext as _, Entity, Focusable as _, Modifiers, TestAppContext, WindowHandle, px, + }; use language::{Buffer, DiskState, Point}; use project::Project; use serde_json::json; @@ -1845,12 +2036,13 @@ mod tests { fs.insert_tree( path!("/project"), json!({ - "docs": {"guide.md": "alpha\nbeta\ngamma\n"}, + "docs": {"guide.txt": "alpha\nbeta\ngamma\n"}, "src": {"main.rs": "first\nsecond\nthird\n"} }), ) .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + register_markdown_language(&project, cx); let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); let workspace = @@ -1862,6 +2054,7 @@ mod tests { "../src/main.rs:2:4".into(), None, Some(PathBuf::from(path!("/project/docs"))), + None, &workspace_weak, window, cx, @@ -1888,6 +2081,7 @@ mod tests { "../src/main.rs".into(), Some("L3".into()), Some(PathBuf::from(path!("/project/docs"))), + None, &workspace_weak, window, cx, @@ -1903,12 +2097,13 @@ mod tests { ); }); - // `guide.md:2` must open the sibling file at line 2 despite `guide.md:` being a valid URI scheme prefix. + // `guide.txt:2` must open the sibling file at line 2 despite `guide.txt:` being a valid URI scheme prefix. multi_workspace.update_in(cx, |_, window, cx| { open_preview_url( - "guide.md:2".into(), + "guide.txt:2".into(), None, Some(PathBuf::from(path!("/project/docs"))), + None, &workspace_weak, window, cx, @@ -1920,7 +2115,7 @@ mod tests { workspace .active_item(cx) .and_then(|item| item.downcast::()) - .expect("guide.md should be open in an editor") + .expect("guide.txt should be open in an editor") }); guide_editor.update_in(cx, |editor, window, cx| { let file_path = editor @@ -1929,7 +2124,7 @@ mod tests { .as_singleton() .and_then(|buffer| buffer.read(cx).file().map(|file| file.path().clone())) .expect("opened editor should have a file"); - assert_eq!(file_path.as_ref(), rel_path("docs/guide.md")); + assert_eq!(file_path.as_ref(), rel_path("docs/guide.txt")); let snapshot = editor.snapshot(window, cx); assert_eq!( editor.selections.newest::(&snapshot).head(), @@ -1943,6 +2138,7 @@ mod tests { "main.rs:2".into(), None, Some(PathBuf::from(path!("/project/docs"))), + None, &workspace_weak, window, cx, @@ -1965,6 +2161,7 @@ mod tests { "tel:123".into(), None, Some(PathBuf::from(path!("/project/docs"))), + None, &workspace_weak, window, cx, @@ -1974,6 +2171,308 @@ mod tests { assert_eq!(cx.opened_url().as_deref(), Some("tel:123")); } + #[gpui::test] + async fn opens_markdown_links_in_preview(cx: &mut TestAppContext) { + init_test(cx); + + let mut notes = String::from("# Notes\n\nintro\n\n"); + for _ in 0..100 { + notes.push_str("filler line\n\n"); + } + notes.push_str("## Target Section\n\nfound it\n"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + "notes.md": notes, + "other.md": "# Other\n\nshort\n", + }), + ) + .await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + register_markdown_language(&project, cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "notes.md".into(), + Some("target-section".into()), + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + + let preview = workspace.read_with(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "markdown links must not open raw editors" + ); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("notes.md should open in a Markdown preview") + }); + assert_eq!( + preview_source_path(cx, &preview).as_ref(), + rel_path("notes.md") + ); + let heading_scroll_offset = + preview.read_with(cx, |preview, _| preview.scroll_handle.offset()); + assert!( + heading_scroll_offset.y < px(0.), + "preview should scroll down to the linked heading, got {heading_scroll_offset:?}" + ); + + let preview_editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + preview_editor.update_in(cx, |editor, _, cx| { + let end = editor.buffer().read(cx).snapshot(cx).len(); + editor.edit([(end..end, "\nappended\n")], cx); + }); + cx.executor().advance_clock(Duration::from_millis(300)); + cx.run_until_parked(); + assert_eq!( + preview.read_with(cx, |preview, _| preview.scroll_handle.offset()), + heading_scroll_offset, + "reparsing after an edit must not replay the consumed heading scroll" + ); + + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "notes.md".into(), + None, + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 1 + ); + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + Some(preview.entity_id()) + ); + }); + + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "notes.md:105:1".into(), + None, + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "a position on a markdown link must not open a raw editor" + ); + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + Some(preview.entity_id()) + ); + }); + preview_editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + editor.selections.newest::(&snapshot).head(), + Point::new(104, 0) + ); + }); + + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "notes.md".into(), + Some("L3".into()), + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + workspace.read_with(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "an `#L` fragment on a markdown link must not open a raw editor" + ); + assert_eq!( + workspace.active_item(cx).map(|item| item.item_id()), + Some(preview.entity_id()) + ); + }); + preview_editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + editor.selections.newest::(&snapshot).head(), + Point::new(2, 0) + ); + }); + + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "other.md".into(), + Some("missing-heading".into()), + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + let other_preview = workspace.read_with(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 2 + ); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("other.md should open in a Markdown preview") + }); + assert_eq!( + preview_source_path(cx, &other_preview).as_ref(), + rel_path("other.md") + ); + assert_eq!( + other_preview + .read_with(cx, |preview, _| preview.scroll_handle.offset()) + .y, + px(0.), + "a dead anchor must open the document at the top" + ); + + cx.simulate_modifiers_change(Modifiers::alt()); + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "notes.md:5:3".into(), + None, + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + cx.simulate_modifiers_change(Modifiers::default()); + + let raw_editor = workspace.read_with(cx, |workspace, cx| { + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("alt-click should open the raw editor") + }); + raw_editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + editor.selections.newest::(&snapshot).head(), + Point::new(4, 2) + ); + }); + + preview.read_with(cx, |preview, _| { + assert_eq!( + preview + .active_editor + .as_ref() + .map(|state| state.editor.clone()), + Some(raw_editor.clone()) + ); + }); + } + + #[gpui::test] + async fn scrolls_position_link_in_newly_created_preview(cx: &mut TestAppContext) { + init_test(cx); + + let mut notes = String::from("# Notes\n\nintro\n\n"); + for _ in 0..100 { + notes.push_str("filler line\n\n"); + } + notes.push_str("## Target Section\n\nfound it\n"); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/project"), json!({ "notes.md": notes })) + .await; + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + register_markdown_language(&project, cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + let workspace_weak = workspace.downgrade(); + + multi_workspace.update_in(cx, |_, window, cx| { + open_preview_url( + "notes.md:180:1".into(), + None, + Some(PathBuf::from(path!("/project"))), + None, + &workspace_weak, + window, + cx, + ); + }); + cx.run_until_parked(); + + let preview = workspace.read_with(cx, |workspace, cx| { + assert_eq!( + workspace.items_of_type::(cx).count(), + 0, + "a position on a markdown link must not open a raw editor" + ); + workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("notes.md should open in a Markdown preview") + }); + assert_eq!( + preview_source_path(cx, &preview).as_ref(), + rel_path("notes.md") + ); + let preview_editor = preview.read_with(cx, |preview, _| { + preview.active_editor.as_ref().unwrap().editor.clone() + }); + preview_editor.update_in(cx, |editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + assert_eq!( + editor.selections.newest::(&snapshot).head(), + Point::new(179, 0) + ); + }); + let scroll_offset = preview.read_with(cx, |preview, _| preview.scroll_handle.offset()); + assert!( + scroll_offset.y < px(0.), + "a new preview should scroll down to the linked position, got {scroll_offset:?}" + ); + } + #[gpui::test] async fn toggles_task_checkbox_and_saves_when_preview_is_active(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -2894,6 +3393,21 @@ mod tests { }) } + fn register_markdown_language(project: &Entity, cx: &mut TestAppContext) { + let languages = project.read_with(cx, |project, _| project.languages().clone()); + languages.add(Arc::new(language::Language::new( + language::LanguageConfig { + name: "Markdown".into(), + matcher: Arc::new(language::LanguageMatcher { + path_suffixes: vec!["md".to_string(), "markdown".to_string()], + ..Default::default() + }), + ..Default::default() + }, + None, + ))); + } + async fn wait_for_preview_serialization(cx: &mut TestAppContext) { cx.run_until_parked(); cx.executor().advance_clock(Duration::from_millis(250)); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 6722fee9ec..670bdd4098 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1092,7 +1092,11 @@ impl ProjectPanel { let is_remote = project.is_remote(); let is_collab = project.is_via_collab(); let is_local = project.is_local() || project.is_via_wsl_with_host_interop(cx); - let is_markdown = !is_dir && MarkdownPreviewView::is_markdown_path(&*entry.path); + let is_markdown = !is_dir + && MarkdownPreviewView::is_markdown_path( + entry.path.as_std_path(), + project.languages(), + ); let settings = ProjectPanelSettings::get_global(cx); let visible_worktrees_count = project.visible_worktrees(cx).count(); @@ -1783,7 +1787,12 @@ impl ProjectPanel { let Some((worktree, entry)) = self.selected_entry(cx) else { return; }; - if !entry.is_file() || !MarkdownPreviewView::is_markdown_path(&*entry.path) { + if !entry.is_file() + || !MarkdownPreviewView::is_markdown_path( + entry.path.as_std_path(), + self.project.read(cx).languages(), + ) + { return; } let project_path = ProjectPath { diff --git a/crates/util/src/markdown.rs b/crates/util/src/markdown.rs index 8c45f98bf8..92c3ffedc9 100644 --- a/crates/util/src/markdown.rs +++ b/crates/util/src/markdown.rs @@ -59,12 +59,12 @@ pub fn split_local_url_fragment(url: &str) -> (&str, Option<&str>) { pub fn source_position_from_fragment(fragment: &str) -> Option<(u32, u32)> { let fragment = fragment.strip_prefix('L').unwrap_or(fragment); - let (line, column) = match fragment.split_once([',', ':']) { + let fragment = fragment + .split_once('-') + .map_or(fragment, |(start, _)| start); + let (line, column) = match fragment.split_once([',', ':', 'C']) { Some((line, column)) => (line, Some(column)), - None => ( - fragment.split_once('-').map_or(fragment, |(line, _)| line), - None, - ), + None => (fragment, None), }; let line = line.parse::().ok()?.checked_sub(1)?; let column = column @@ -373,6 +373,8 @@ mod tests { assert_eq!(source_position_from_fragment("33,33"), Some((32, 32))); assert_eq!(source_position_from_fragment("L42"), Some((41, 0))); assert_eq!(source_position_from_fragment("L42:7"), Some((41, 6))); + assert_eq!(source_position_from_fragment("L3C5"), Some((2, 4))); + assert_eq!(source_position_from_fragment("L3C5-L8C10"), Some((2, 4))); assert_eq!(source_position_from_fragment("5"), Some((4, 0))); assert_eq!(source_position_from_fragment("L10-L20"), Some((9, 0))); assert_eq!(source_position_from_fragment("heading"), None);