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 `#L<line>C<column>` link fragments not placing the
cursor at the linked position.
This commit is contained in:
Smit Barmase authored and GitHub committed 2026-07-28 13:56:46 +00:00
1 parent b6ebe0ff35
commit 1102219f81
4 files changed
+633 -71

No files matched your search

+47 -10
View File
@@ -395,6 +395,8 @@ pub struct Markdown {
pressed_link: Option<RenderedLink>,
pressed_footnote_ref: Option<RenderedFootnoteRef>,
autoscroll_request: Option<usize>,
pending_heading_scroll: Option<SharedString>,
pending_autoscroll: Option<usize>,
active_root_block: Option<usize>,
parsed_markdown: ParsedMarkdown,
images_by_source_offset: HashMap<usize, Arc<Image>>,
@@ -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<Self>) {
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<Self>) -> Option<usize> {
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>,
) {
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<Self>) {
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>, 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
}
@@ -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>) {
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::<MarkdownPreviewView>()
.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<Buffer>, 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<Workspace>,
@@ -363,10 +368,14 @@ impl MarkdownPreviewView {
}
}
pub fn is_markdown_path(path: impl AsRef<Path>) -> 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<Path>, languages: &Arc<LanguageRegistry>) -> 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<PreviewLinkTarget>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Editor>,
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<SharedString>,
base_directory: Option<PathBuf>,
source_view: Option<WeakEntity<MarkdownPreviewView>>,
workspace: &WeakEntity<Workspace>,
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<Buffer>,
cx: &App,
) -> Option<Entity<MarkdownPreviewView>> {
workspace
.items_of_type::<MarkdownPreviewView>(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<PreviewLinkTarget>,
fallback_url: String,
base_directory: Option<PathBuf>,
source_view: Option<WeakEntity<MarkdownPreviewView>>,
workspace: &WeakEntity<Workspace>,
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::<Editor>())
.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::<Point>(&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::<Editor>(cx).count(),
0,
"markdown links must not open raw editors"
);
workspace
.active_item(cx)
.and_then(|item| item.downcast::<MarkdownPreviewView>())
.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::<MarkdownPreviewView>(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::<Editor>(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::<Point>(&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::<Editor>(cx).count(),
0,
"an `#L<row>` 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::<Point>(&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::<MarkdownPreviewView>(cx).count(),
2
);
workspace
.active_item(cx)
.and_then(|item| item.downcast::<MarkdownPreviewView>())
.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::<Editor>())
.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::<Point>(&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::<Editor>(cx).count(),
0,
"a position on a markdown link must not open a raw editor"
);
workspace
.active_item(cx)
.and_then(|item| item.downcast::<MarkdownPreviewView>())
.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::<Point>(&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<Project>, 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));
+11 -2
View File
@@ -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 {
+7 -5
View File
@@ -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::<u32>().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);