feat(app): support file:// protocol URLs for opening workspace files
This commit is contained in:
@@ -71,7 +71,11 @@ import {
|
|||||||
buildToolCallDisplayModel,
|
buildToolCallDisplayModel,
|
||||||
} from "@/utils/tool-call-display";
|
} from "@/utils/tool-call-display";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
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 { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||||
@@ -505,6 +509,19 @@ function getInlineCodeAutoLinkUrl(
|
|||||||
return match.url;
|
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) => ({
|
const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
@@ -727,16 +744,35 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
|
||||||
const markdownParser = useMemo(
|
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 handleLinkPress = useCallback((url: string) => {
|
||||||
|
const fileTarget = onInlinePathPress
|
||||||
|
? parseFileProtocolUrl(url)
|
||||||
|
: null;
|
||||||
|
if (fileTarget) {
|
||||||
|
onInlinePathPress?.(fileTarget);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void openExternalUrl(url);
|
void openExternalUrl(url);
|
||||||
// react-native-markdown-display opens the link itself when this returns true.
|
// react-native-markdown-display opens the link itself when this returns true.
|
||||||
// We already handled it above, so return false to avoid duplicate opens.
|
// We already handled it above, so return false to avoid duplicate opens.
|
||||||
return false;
|
return false;
|
||||||
}, []);
|
}, [onInlinePathPress]);
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -793,12 +829,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
code_inline: (
|
code_inline: (
|
||||||
node: any,
|
node: any,
|
||||||
_children: ReactNode[],
|
_children: ReactNode[],
|
||||||
_parent: any,
|
parent: any,
|
||||||
styles: any,
|
styles: any,
|
||||||
inheritedStyles: any = {}
|
inheritedStyles: any = {}
|
||||||
) => {
|
) => {
|
||||||
const content = node.content ?? "";
|
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)
|
? parseInlinePathToken(content)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseInlinePathToken } from "./inline-path";
|
import { parseFileProtocolUrl, parseInlinePathToken } from "./inline-path";
|
||||||
|
|
||||||
describe("parseInlinePathToken", () => {
|
describe("parseInlinePathToken", () => {
|
||||||
it("returns null for plain paths (no line)", () => {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface InlinePathTarget {
|
|||||||
lineEnd?: number;
|
lineEnd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILE_PROTOCOL = "file:";
|
||||||
|
|
||||||
function normalizePathToken(value: string): string | null {
|
function normalizePathToken(value: string): string | null {
|
||||||
const trimmed = value
|
const trimmed = value
|
||||||
.trim()
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user