Skip to content

Commit 08f6339

Browse files
committed
fix: parse file url anchors without re-parsing paths
1 parent 158bba5 commit 08f6339

4 files changed

Lines changed: 63 additions & 6 deletions

File tree

src/features/messages/components/Markdown.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,4 +566,26 @@ describe("Markdown file-like href behavior", () => {
566566
expect(clickEvent.defaultPrevented).toBe(true);
567567
expect(onOpenFileLink).toHaveBeenCalledWith("//server/share/100%.tsx:12");
568568
});
569+
570+
it("keeps encoded #L-like filenames intact when opening file URLs", () => {
571+
const onOpenFileLink = vi.fn();
572+
render(
573+
<Markdown
574+
value="See [report](file:///tmp/report%23L12.md)"
575+
className="markdown"
576+
onOpenFileLink={onOpenFileLink}
577+
/>,
578+
);
579+
580+
const link = screen.getByText("report").closest("a");
581+
expect(link?.getAttribute("href")).toBe("file:///tmp/report%23L12.md");
582+
583+
const clickEvent = createEvent.click(link as Element, {
584+
bubbles: true,
585+
cancelable: true,
586+
});
587+
fireEvent(link as Element, clickEvent);
588+
expect(clickEvent.defaultPrevented).toBe(true);
589+
expect(onOpenFileLink).toHaveBeenCalledWith("/tmp/report#L12.md");
590+
});
569591
});

src/features/messages/components/Markdown.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,8 @@ export function Markdown({
836836
remarkPlugins={[remarkGfm, remarkFileLinks]}
837837
urlTransform={(url) => {
838838
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url);
839+
// Keep file-like hrefs intact before scheme sanitization runs, otherwise
840+
// Windows absolute paths such as C:/repo/file.ts look like unknown schemes.
839841
if (resolveHrefFilePath(url)) {
840842
return url;
841843
}

src/utils/fileLinks.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ function withThrowingUrlConstructor(run: () => void) {
2525
}
2626

2727
describe("fromFileUrl", () => {
28+
it("keeps encoded #L-like path segments as part of the decoded filename", () => {
29+
expect(fromFileUrl("file:///tmp/%23L12")).toBe("/tmp/#L12");
30+
expect(fromFileUrl("file:///tmp/report%23L12C3.md")).toBe("/tmp/report#L12C3.md");
31+
});
32+
33+
it("uses only the real URL fragment as a line anchor", () => {
34+
expect(fromFileUrl("file:///tmp/report%23L12.md#L34")).toBe("/tmp/report#L12.md:34");
35+
expect(fromFileUrl("file:///tmp/report%23L12C3.md#L34C2")).toBe(
36+
"/tmp/report#L12C3.md:34:2",
37+
);
38+
});
39+
2840
it("keeps Windows drive paths when decoding a file URL with an unescaped percent", () => {
2941
expect(fromFileUrl("file:///C:/repo/100%.tsx#L12")).toBe("C:/repo/100%.tsx:12");
3042
});
@@ -51,4 +63,11 @@ describe("fromFileUrl", () => {
5163
);
5264
});
5365
});
66+
67+
it("keeps encoded #L-like path segments when the URL constructor fallback is used", () => {
68+
withThrowingUrlConstructor(() => {
69+
expect(fromFileUrl("file:///tmp/%23L12")).toBe("/tmp/#L12");
70+
expect(fromFileUrl("file:///tmp/report%23L12.md#L34")).toBe("/tmp/report#L12.md:34");
71+
});
72+
});
5473
});

src/utils/fileLinks.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type ParsedFileLocation = {
77
const FILE_LOCATION_SUFFIX_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
88
const FILE_LOCATION_RANGE_SUFFIX_PATTERN = /^(.*?):(\d+)-(\d+)$/;
99
const FILE_LOCATION_HASH_PATTERN = /^(.*?)#L(\d+)(?:C(\d+))?$/i;
10+
const FILE_URL_LOCATION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i;
1011

1112
export const FILE_LINK_SUFFIX_SOURCE =
1213
"(?:(?::\\d+(?::\\d+)?|:\\d+-\\d+)|(?:#L\\d+(?:C\\d+)?))?";
@@ -27,8 +28,21 @@ function decodeURIComponentSafely(value: string) {
2728
}
2829
}
2930

30-
function normalizeRecognizedFileUrlHash(hash: string) {
31-
return FILE_LOCATION_HASH_PATTERN.test(hash) ? hash : "";
31+
function parseRecognizedFileUrlHash(hash: string) {
32+
const match = hash.match(FILE_URL_LOCATION_HASH_PATTERN);
33+
if (!match) {
34+
return {
35+
line: null,
36+
column: null,
37+
};
38+
}
39+
40+
const [, lineValue, columnValue] = match;
41+
const line = parsePositiveInteger(lineValue);
42+
return {
43+
line,
44+
column: line === null ? null : parsePositiveInteger(columnValue),
45+
};
3246
}
3347

3448
function buildLocalPathFromFileUrl(host: string, pathname: string) {
@@ -272,15 +286,15 @@ export function fromFileUrl(url: string) {
272286
}
273287

274288
const path = buildLocalPathFromFileUrl(parsed.host, parsed.pathname);
275-
const normalizedHash = normalizeRecognizedFileUrlHash(parsed.hash);
276-
return normalizeFileLinkPath(`${path}${normalizedHash}`);
289+
const { line, column } = parseRecognizedFileUrlHash(parsed.hash);
290+
return formatFileLocation(path, line, column);
277291
} catch {
278292
const manualParts = parseManualFileUrl(url);
279293
if (!manualParts) {
280294
return null;
281295
}
282296
const path = buildLocalPathFromFileUrl(manualParts.host, manualParts.pathname);
283-
const normalizedHash = normalizeRecognizedFileUrlHash(manualParts.hash);
284-
return normalizeFileLinkPath(`${path}${normalizedHash}`);
297+
const { line, column } = parseRecognizedFileUrlHash(manualParts.hash);
298+
return formatFileLocation(path, line, column);
285299
}
286300
}

0 commit comments

Comments
 (0)