mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(editor): follow local Markdown anchor links (#1328)
Go to Declaration and modifier-click on a fragment-only Markdown or HTML link such as [artist::order](#Artist-order) said there was nothing to go to: the text-reference fallback rejected fragment-only targets, and Markdown fragment lookup checked headings only, never HTML id / name anchors. Fragment-only href targets are now recognized and resolve in the same file (the current buffer, so unsaved anchors count), and Markdown files fall back to id / name anchors after headings. Co-authored-by: pravdomil <2387356+pravdomil@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { fragmentOffset, linkReference, looksLikePath, textReference } from "./links";
|
||||
import { fragmentOffset, linkReference, looksLikePath, resolvePath, textReference } from "./links";
|
||||
|
||||
const at = (src: string, marker = "|") => {
|
||||
const offset = src.indexOf(marker);
|
||||
@@ -28,6 +28,20 @@ describe("text references", () => {
|
||||
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "img/logo.svg" });
|
||||
});
|
||||
|
||||
it("finds fragment-only link targets", () => {
|
||||
for (const source of ["[artist::order](#Artist-or|der)", '<a href="#Artist-or|der">order</a>']) {
|
||||
const { text, offset } = at(source);
|
||||
expect(textReference(text, offset)).toMatchObject({ kind: "path", value: "#Artist-order" });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat arbitrary fragment strings as links", () => {
|
||||
for (const source of ['const color = "#ff|f"', '<a id="Artist-or|der">', "url(#ic|on)"]) {
|
||||
const { text, offset } = at(source);
|
||||
expect(textReference(text, offset)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores prose and identifiers", () => {
|
||||
expect(looksLikePath("hello world.txt")).toBe(false);
|
||||
expect(looksLikePath("utf-8")).toBe(false);
|
||||
@@ -41,6 +55,18 @@ describe("text references", () => {
|
||||
});
|
||||
|
||||
describe("links and fragments", () => {
|
||||
it("resolves local fragments without probing the filesystem", async () => {
|
||||
const ref = { kind: "path" as const, from: 5, to: 20, value: "#Artist%2Dorder" };
|
||||
expect(await resolvePath(ref, "/p/notes.md")).toEqual({
|
||||
kind: "file",
|
||||
from: 5,
|
||||
to: 20,
|
||||
path: "/p/notes.md",
|
||||
fragment: "Artist-order",
|
||||
});
|
||||
expect(await resolvePath({ ...ref, value: "#bad%" }, "/p/notes.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the server link around the offset", () => {
|
||||
const text = Text.of(['<a href="b.html#top">']);
|
||||
const range = { start: { line: 0, col: 9 }, end: { line: 0, col: 19 } };
|
||||
@@ -55,6 +81,12 @@ describe("links and fragments", () => {
|
||||
const html = '<p>\n<div class="x" id="main">';
|
||||
expect(fragmentOffset(html, "/i.html", "main")).toBe(html.indexOf("id="));
|
||||
expect(fragmentOffset("# Intro\n\n## Getting Started!\n", "/r.md", "getting-started")).toBe(9);
|
||||
for (const path of ["/r.md", "/r.markdown", "/r.mdx"]) {
|
||||
for (const attr of ["id", "name"]) {
|
||||
const source = `[artist::order](#Artist-order)\n\n<a ${attr}="Artist-order"></a>`;
|
||||
expect(fragmentOffset(source, path, "Artist-order")).toBe(source.indexOf(`${attr}=`));
|
||||
}
|
||||
}
|
||||
expect(fragmentOffset("x", "/i.html", "nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+16
-5
@@ -87,14 +87,19 @@ export function textReference(text: Text, offset: number): Reference | null {
|
||||
return { kind: "url", from: line.from + m.index, to: line.from + m.index + m[0].length, url: m[0] };
|
||||
}
|
||||
const strings = [
|
||||
...[...line.text.matchAll(QUOTED_RE)].map((m) => ({ value: m[1] ?? m[2] ?? m[3] ?? "", from: m.index + 1 })),
|
||||
...[...line.text.matchAll(QUOTED_RE)].map((m) => ({
|
||||
value: m[1] ?? m[2] ?? m[3] ?? "",
|
||||
from: m.index + 1,
|
||||
fragment: /\bhref\s*=\s*$/i.test(line.text.slice(0, m.index)),
|
||||
})),
|
||||
...[...line.text.matchAll(BARE_RE)].map((m) => {
|
||||
const value = m[1] ?? m[2] ?? "";
|
||||
return { value, from: m.index + m[0].indexOf(value) };
|
||||
return { value, from: m.index + m[0].indexOf(value), fragment: m[1] !== undefined };
|
||||
}),
|
||||
];
|
||||
for (const { value, from } of strings) {
|
||||
if (col < from || col > from + value.length || !looksLikePath(value)) continue;
|
||||
for (const { value, from, fragment } of strings) {
|
||||
if (col < from || col > from + value.length || (!looksLikePath(value) && !(fragment && /^#[^\s]+$/.test(value))))
|
||||
continue;
|
||||
return { kind: "path", from: line.from + from, to: line.from + from + value.length, value };
|
||||
}
|
||||
return null;
|
||||
@@ -113,6 +118,13 @@ async function isFile(path: string): Promise<boolean> {
|
||||
/** Resolves a `path` reference (in the backend, like IDEA's file references); null when nothing exists there. */
|
||||
export async function resolvePath(ref: Reference, from: string): Promise<Reference | null> {
|
||||
if (ref.kind !== "path") return ref;
|
||||
if (ref.value.startsWith("#")) {
|
||||
try {
|
||||
return { kind: "file", from: ref.from, to: ref.to, path: from, fragment: decodeURIComponent(ref.value.slice(1)) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!isTauri()) return null;
|
||||
const path = await call(commands.fsResolveReference(from, ref.value)).catch(() => null);
|
||||
if (!path) return null;
|
||||
@@ -142,7 +154,6 @@ export function fragmentOffset(text: string, path: string, fragment: string): nu
|
||||
if (h && slug(h[1]!) === fragment.toLowerCase()) return offset;
|
||||
offset += l.length + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const m = new RegExp(`\\b(?:id|name)\\s*=\\s*["']?${esc}(?=["'\\s>])`).exec(text);
|
||||
return m ? m.index : null;
|
||||
|
||||
@@ -50,7 +50,15 @@ import { showHint, showPendingHint } from "./hint";
|
||||
import { outsideProjectReason } from "./outsideProject";
|
||||
import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
|
||||
import { readManyFileLines, targetLabel } from "./files";
|
||||
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
|
||||
import {
|
||||
documentLinks,
|
||||
followReference,
|
||||
fragmentOffset,
|
||||
knownLinks,
|
||||
linkReference,
|
||||
referenceAt,
|
||||
textReference,
|
||||
} from "./links";
|
||||
import { featureBackoff } from "./availability";
|
||||
import { requestHover, showPhpConstructDocumentation } from "./hover";
|
||||
import { declarationSummary, renderCode } from "./markdown";
|
||||
@@ -277,6 +285,14 @@ async function followReferenceAt(view: EditorView, doc: EditorDocContext, offset
|
||||
const ref = await referenceAt(view, doc, offset);
|
||||
if (!ref) return false;
|
||||
try {
|
||||
if (ref.kind === "file" && ref.path === doc.path && ref.fragment) {
|
||||
const target = fragmentOffset(view.state.doc.toString(), doc.path, ref.fragment);
|
||||
if (target !== null) {
|
||||
view.dispatch({ selection: EditorSelection.cursor(target), effects: EditorView.scrollIntoView(target) });
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await followReference(ref);
|
||||
} catch (e) {
|
||||
showHint(view, e instanceof Error ? e.message : String(e), { tone: "error" });
|
||||
|
||||
@@ -82,15 +82,12 @@ const local = (over: Partial<Extract<Locals, { status: "local" }>["symbol"]> = {
|
||||
});
|
||||
|
||||
const views: EditorView[] = [];
|
||||
function editor(doc: string, language = "typescript", anchor = 0) {
|
||||
function editor(doc: string, language = "typescript", anchor = 0, path = "/p/a.ts") {
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: { anchor },
|
||||
extensions: [
|
||||
lspDoc.of({ doc: 7, path: "/p/a.ts", language, uiMode: null, readOnly: false }),
|
||||
navigationExtension(),
|
||||
],
|
||||
extensions: [lspDoc.of({ doc: 7, path, language, uiMode: null, readOnly: false }), navigationExtension()],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
@@ -670,3 +667,16 @@ describe("modifier-hover navigation recovery", () => {
|
||||
expect(view.dom.querySelector(".cm-lsp-link")?.textContent).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("same-document fragment navigation", () => {
|
||||
it.each(["definition", "declaration"] as const)(
|
||||
"follows Markdown HTML anchors through %s without a server",
|
||||
async (kind) => {
|
||||
const source = '[artist::order](#Artist-order)\n\n<a id="Artist-order"></a>';
|
||||
const view = editor(source, "markdown", source.indexOf("#Artist"), "/p/notes.md");
|
||||
await gotoTargets(view, kind);
|
||||
expect(view.state.selection.main.head).toBe(source.indexOf("id="));
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user