diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 6698e6181..f04f21d73 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -71,7 +71,11 @@ import { buildToolCallDisplayModel, } from "@/utils/tool-call-display"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; +import { + parseFileProtocolUrl, + parseInlinePathToken, + type InlinePathTarget, +} from "@/utils/inline-path"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { openExternalUrl } from "@/utils/open-external-url"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; @@ -505,6 +509,19 @@ function getInlineCodeAutoLinkUrl( return match.url; } +function nodeHasParentType(parent: unknown, type: string): boolean { + if (Array.isArray(parent)) { + return parent.some((entry) => entry?.type === type); + } + + return ( + typeof parent === "object" && + parent !== null && + "type" in parent && + (parent as { type?: string }).type === type + ); +} + const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({ container: { alignSelf: "flex-start", @@ -727,16 +744,35 @@ export const AssistantMessage = memo(function AssistantMessage({ const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownParser = useMemo( - () => MarkdownIt({ typographer: true, linkify: true }), + () => { + const parser = MarkdownIt({ typographer: true, linkify: true }); + const defaultValidateLink = parser.validateLink.bind(parser); + parser.validateLink = (url: string) => { + if (url.trim().toLowerCase().startsWith("file://")) { + return true; + } + + return defaultValidateLink(url); + }; + return parser; + }, [] ); const handleLinkPress = useCallback((url: string) => { + const fileTarget = onInlinePathPress + ? parseFileProtocolUrl(url) + : null; + if (fileTarget) { + onInlinePathPress?.(fileTarget); + return false; + } + void openExternalUrl(url); // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; - }, []); + }, [onInlinePathPress]); const markdownRules = useMemo(() => { return { @@ -793,12 +829,16 @@ export const AssistantMessage = memo(function AssistantMessage({ code_inline: ( node: any, _children: ReactNode[], - _parent: any, + parent: any, styles: any, inheritedStyles: any = {} ) => { const content = node.content ?? ""; - const parsed = onInlinePathPress + const isLinkedInlineCode = + nodeHasParentType(parent, "link") || + (!Array.isArray(parent) && + typeof parent?.attributes?.href === "string"); + const parsed = onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null; diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/utils/inline-path.test.ts index 9569c25d2..3766d8163 100644 --- a/packages/app/src/utils/inline-path.test.ts +++ b/packages/app/src/utils/inline-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseInlinePathToken } from "./inline-path"; +import { parseFileProtocolUrl, parseInlinePathToken } from "./inline-path"; describe("parseInlinePathToken", () => { it("returns null for plain paths (no line)", () => { @@ -31,3 +31,42 @@ describe("parseInlinePathToken", () => { }); }); +describe("parseFileProtocolUrl", () => { + it("parses file URLs with line fragments", () => { + expect( + parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81") + ).toEqual({ + raw: "file:///Users/test/project/src/app.tsx#L81", + path: "/Users/test/project/src/app.tsx", + lineStart: 81, + lineEnd: undefined, + }); + }); + + it("parses file URLs without line fragments", () => { + expect( + parseFileProtocolUrl("file:///Users/test/project/src/app.tsx") + ).toEqual({ + raw: "file:///Users/test/project/src/app.tsx", + path: "/Users/test/project/src/app.tsx", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("parses windows file URLs and line ranges", () => { + expect( + parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20") + ).toEqual({ + raw: "file:///C:/Users/test/project/src/app.tsx#L12-L20", + path: "C:/Users/test/project/src/app.tsx", + lineStart: 12, + lineEnd: 20, + }); + }); + + it("rejects non-file URLs and invalid ranges", () => { + expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull(); + expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts index 605b87231..c78eefded 100644 --- a/packages/app/src/utils/inline-path.ts +++ b/packages/app/src/utils/inline-path.ts @@ -5,6 +5,8 @@ export interface InlinePathTarget { lineEnd?: number; } +const FILE_PROTOCOL = "file:"; + function normalizePathToken(value: string): string | null { const trimmed = value .trim() @@ -79,3 +81,64 @@ export function parseInlinePathToken(value: string): InlinePathTarget | null { }; } +export function parseFileProtocolUrl(value: string): InlinePathTarget | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed); + } catch { + return null; + } + + if (parsedUrl.protocol !== FILE_PROTOCOL) { + return null; + } + + const normalizedPath = normalizeFileUrlPath(parsedUrl.pathname); + if (!normalizedPath) { + return null; + } + + const rawFragment = parsedUrl.hash.startsWith("#") + ? parsedUrl.hash.slice(1) + : parsedUrl.hash; + const lineMatch = rawFragment.match(/^L([0-9]+)(?:-L?([0-9]+))?$/i); + const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined; + const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined; + + if ( + (lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) || + (lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) || + (lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart) + ) { + return null; + } + + return { + raw: value, + path: normalizedPath, + lineStart, + lineEnd, + }; +} + +function normalizeFileUrlPath(pathname: string): string | null { + if (!pathname) { + return null; + } + + const decoded = decodeURIComponent(pathname).replace(/\\/g, "/"); + if (!decoded) { + return null; + } + + if (/^\/[A-Za-z]:\//.test(decoded)) { + return decoded.slice(1); + } + + return decoded; +}