feat(app): support file:// protocol URLs for opening workspace files

This commit is contained in:
Mohamed Boudra
2026-03-13 10:24:01 +07:00
parent debe4b543b
commit bed03be332
3 changed files with 148 additions and 6 deletions
+45 -5
View File
@@ -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;
+40 -1
View File
@@ -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();
});
});
+63
View File
@@ -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;
}