fix(app): route assistant file links to workspace files
This commit is contained in:
@@ -69,6 +69,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceFileRoute } from "@/utils/host-routes";
|
||||||
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -155,7 +156,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeInlinePath(target.path, agent.cwd);
|
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,6 +315,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
message={item.text}
|
message={item.text}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
onInlinePathPress={handleInlinePathPress}
|
onInlinePathPress={handleInlinePathPress}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -656,102 +658,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeInlinePath(
|
|
||||||
rawPath: string,
|
|
||||||
cwd?: string
|
|
||||||
): { directory: string; file?: string } | null {
|
|
||||||
if (!rawPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedInput = normalizePathInput(rawPath);
|
|
||||||
if (!normalizedInput) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized = normalizedInput;
|
|
||||||
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
|
||||||
if (cwdRelative) {
|
|
||||||
normalized = cwdRelative;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.startsWith("./")) {
|
|
||||||
normalized = normalized.slice(2) || ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalized.length) {
|
|
||||||
normalized = ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized === ".") {
|
|
||||||
return { directory: "." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.endsWith("/")) {
|
|
||||||
const dir = normalized.replace(/\/+$/, "");
|
|
||||||
return { directory: dir.length > 0 ? dir : "." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastSlash = normalized.lastIndexOf("/");
|
|
||||||
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
|
||||||
|
|
||||||
return {
|
|
||||||
directory: directory.length > 0 ? directory : ".",
|
|
||||||
file: normalized,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePathInput(value: string | undefined): string | null {
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = value
|
|
||||||
.trim()
|
|
||||||
.replace(/^['"`]/, "")
|
|
||||||
.replace(/['"`]$/, "");
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
|
||||||
const normalizedCwd = normalizePathInput(cwd);
|
|
||||||
if (
|
|
||||||
!normalizedCwd ||
|
|
||||||
!isAbsolutePath(pathValue) ||
|
|
||||||
!isAbsolutePath(normalizedCwd)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
|
||||||
const comparePath = normalizePathForCompare(pathValue);
|
|
||||||
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
|
||||||
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
|
||||||
const comparePrefix = normalizePathForCompare(prefix);
|
|
||||||
|
|
||||||
if (comparePath === compareCwd) {
|
|
||||||
return ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comparePath.startsWith(comparePrefix)) {
|
|
||||||
return pathValue.slice(prefix.length) || ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePathForCompare(value: string): string {
|
|
||||||
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAbsolutePath(value: string): boolean {
|
|
||||||
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkingIndicator() {
|
function WorkingIndicator() {
|
||||||
const dotOne = useSharedValue(0);
|
const dotOne = useSharedValue(0);
|
||||||
const dotTwo = useSharedValue(0);
|
const dotTwo = useSharedValue(0);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ import {
|
|||||||
} 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 {
|
import {
|
||||||
parseFileProtocolUrl,
|
parseAssistantFileLink,
|
||||||
parseInlinePathToken,
|
parseInlinePathToken,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "@/utils/inline-path";
|
} from "@/utils/inline-path";
|
||||||
@@ -429,6 +429,7 @@ interface AssistantMessageProps {
|
|||||||
message: string;
|
message: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||||
|
workspaceRoot?: string;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,6 +745,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
message,
|
message,
|
||||||
timestamp,
|
timestamp,
|
||||||
onInlinePathPress,
|
onInlinePathPress,
|
||||||
|
workspaceRoot,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -770,7 +772,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
|
|
||||||
const handleLinkPress = useCallback((url: string) => {
|
const handleLinkPress = useCallback((url: string) => {
|
||||||
const fileTarget = onInlinePathPress
|
const fileTarget = onInlinePathPress
|
||||||
? parseFileProtocolUrl(url)
|
? parseAssistantFileLink(url, { workspaceRoot })
|
||||||
: null;
|
: null;
|
||||||
if (fileTarget) {
|
if (fileTarget) {
|
||||||
onInlinePathPress?.(fileTarget);
|
onInlinePathPress?.(fileTarget);
|
||||||
@@ -781,7 +783,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
// 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]);
|
}, [onInlinePathPress, workspaceRoot]);
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseFileProtocolUrl, parseInlinePathToken } from "./inline-path";
|
import {
|
||||||
|
normalizeInlinePathTarget,
|
||||||
|
parseAssistantFileLink,
|
||||||
|
parseFileProtocolUrl,
|
||||||
|
parseInlinePathToken,
|
||||||
|
} from "./inline-path";
|
||||||
|
|
||||||
describe("parseInlinePathToken", () => {
|
describe("parseInlinePathToken", () => {
|
||||||
it("returns null for plain paths (no line)", () => {
|
it("returns null for plain paths without a line number", () => {
|
||||||
expect(parseInlinePathToken("src/app.ts")).toBeNull();
|
expect(parseInlinePathToken("src/app.ts")).toBeNull();
|
||||||
expect(parseInlinePathToken("README.md")).toBeNull();
|
expect(parseInlinePathToken("README.md")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -67,6 +72,119 @@ describe("parseFileProtocolUrl", () => {
|
|||||||
|
|
||||||
it("rejects non-file URLs and invalid ranges", () => {
|
it("rejects non-file URLs and invalid ranges", () => {
|
||||||
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
|
||||||
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
|
expect(
|
||||||
|
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseAssistantFileLink", () => {
|
||||||
|
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
raw: "/Users/test/project/src/app.tsx#L33",
|
||||||
|
path: "/Users/test/project/src/app.tsx",
|
||||||
|
lineStart: 33,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses absolute Windows hrefs inside the active workspace", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
|
||||||
|
workspaceRoot: "C:/repo",
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
raw: "C:/repo/src/app.tsx#L12-L20",
|
||||||
|
path: "C:/repo/src/app.tsx",
|
||||||
|
lineStart: 12,
|
||||||
|
lineEnd: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows file URLs even when they are outside the workspace root", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("file:///tmp/outside.txt", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
raw: "file:///tmp/outside.txt",
|
||||||
|
path: "/tmp/outside.txt",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects absolute hrefs outside the workspace root", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("/tmp/outside.txt", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects external URLs", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid line fragments", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeInlinePathTarget", () => {
|
||||||
|
it("keeps relative file paths as file targets", () => {
|
||||||
|
expect(
|
||||||
|
normalizeInlinePathTarget("packages/app/src/components/message.tsx")
|
||||||
|
).toEqual({
|
||||||
|
directory: "packages/app/src/components",
|
||||||
|
file: "packages/app/src/components/message.tsx",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves absolute paths under cwd back to workspace-relative paths", () => {
|
||||||
|
expect(
|
||||||
|
normalizeInlinePathTarget(
|
||||||
|
"/Users/test/project/packages/app/src/components/message.tsx",
|
||||||
|
"/Users/test/project"
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
directory: "packages/app/src/components",
|
||||||
|
file: "packages/app/src/components/message.tsx",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps absolute paths outside cwd as absolute file targets", () => {
|
||||||
|
expect(
|
||||||
|
normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")
|
||||||
|
).toEqual({
|
||||||
|
directory: "/tmp",
|
||||||
|
file: "/tmp/message.tsx",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats cwd itself as the workspace root directory", () => {
|
||||||
|
expect(
|
||||||
|
normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")
|
||||||
|
).toEqual({
|
||||||
|
directory: ".",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps trailing-slash paths as directories", () => {
|
||||||
|
expect(
|
||||||
|
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project")
|
||||||
|
).toEqual({
|
||||||
|
directory: "packages/app",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ export interface InlinePathTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILE_PROTOCOL = "file:";
|
const FILE_PROTOCOL = "file:";
|
||||||
|
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
|
||||||
|
|
||||||
|
export interface AssistantHrefParseOptions {
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedInlinePathTarget {
|
||||||
|
directory: string;
|
||||||
|
file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePathToken(value: string): string | null {
|
function normalizePathToken(value: string): string | null {
|
||||||
const trimmed = value
|
const trimmed = value
|
||||||
@@ -20,6 +30,27 @@ function normalizePathToken(value: string): string | null {
|
|||||||
return trimmed.replace(/\\/g, "/");
|
return trimmed.replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLineFragment(value: string): Pick<InlinePathTarget, "lineStart" | "lineEnd"> | null {
|
||||||
|
const rawFragment = value.startsWith("#") ? value.slice(1) : value;
|
||||||
|
if (!rawFragment) {
|
||||||
|
return { lineStart: undefined, lineEnd: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineMatch = rawFragment.match(INLINE_LINE_FRAGMENT);
|
||||||
|
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 { lineStart, lineEnd };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strict VSCode-style markers only.
|
* Strict VSCode-style markers only.
|
||||||
*
|
*
|
||||||
@@ -103,29 +134,151 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawFragment = parsedUrl.hash.startsWith("#")
|
const lines = parseLineFragment(parsedUrl.hash);
|
||||||
? parsedUrl.hash.slice(1)
|
if (!lines) {
|
||||||
: 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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw: value,
|
raw: value,
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
lineStart,
|
...lines,
|
||||||
lineEnd,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAssistantFileLink(
|
||||||
|
value: string,
|
||||||
|
options: AssistantHrefParseOptions = {}
|
||||||
|
): InlinePathTarget | null {
|
||||||
|
const fileUrlTarget = parseFileProtocolUrl(value);
|
||||||
|
if (fileUrlTarget) {
|
||||||
|
return fileUrlTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.includes("://")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||||
|
if (windowsPathMatch) {
|
||||||
|
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||||
|
if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||||
|
if (!lines) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: value,
|
||||||
|
path: normalizedPath,
|
||||||
|
...lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname));
|
||||||
|
if (!normalizedPath || !normalizedPath.startsWith("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = parseLineFragment(parsedUrl.hash);
|
||||||
|
if (!lines) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: value,
|
||||||
|
path: normalizedPath,
|
||||||
|
...lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeInlinePathTarget(
|
||||||
|
rawPath: string,
|
||||||
|
cwd?: string
|
||||||
|
): NormalizedInlinePathTarget | null {
|
||||||
|
if (!rawPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedInput = normalizePathInput(rawPath);
|
||||||
|
if (!normalizedInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalizedInput;
|
||||||
|
const cwdRelative = resolvePathAgainstCwd(normalized, cwd);
|
||||||
|
if (cwdRelative) {
|
||||||
|
normalized = cwdRelative;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("./")) {
|
||||||
|
normalized = normalized.slice(2) || ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.length) {
|
||||||
|
normalized = ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === ".") {
|
||||||
|
return { directory: "." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.endsWith("/")) {
|
||||||
|
const dir = normalized.replace(/\/+$/, "");
|
||||||
|
return { directory: dir.length > 0 ? dir : "." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSlash = normalized.lastIndexOf("/");
|
||||||
|
const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : ".";
|
||||||
|
|
||||||
|
return {
|
||||||
|
directory: directory.length > 0 ? directory : ".",
|
||||||
|
file: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boolean {
|
||||||
|
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||||
|
if (!normalizedWorkspaceRoot) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparePath = normalizePathForCompare(pathValue);
|
||||||
|
const compareWorkspaceRoot = normalizePathForCompare(
|
||||||
|
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/"
|
||||||
|
);
|
||||||
|
const comparePrefix =
|
||||||
|
compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
comparePath === compareWorkspaceRoot ||
|
||||||
|
comparePath.startsWith(comparePrefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFileUrlPath(pathname: string): string | null {
|
function normalizeFileUrlPath(pathname: string): string | null {
|
||||||
if (!pathname) {
|
if (!pathname) {
|
||||||
return null;
|
return null;
|
||||||
@@ -142,3 +295,54 @@ function normalizeFileUrlPath(pathname: string): string | null {
|
|||||||
|
|
||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePathInput(value: string | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"`]/, "")
|
||||||
|
.replace(/['"`]$/, "");
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
|
||||||
|
const normalizedCwd = normalizePathInput(cwd);
|
||||||
|
if (
|
||||||
|
!normalizedCwd ||
|
||||||
|
!isAbsolutePath(pathValue) ||
|
||||||
|
!isAbsolutePath(normalizedCwd)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/";
|
||||||
|
const comparePath = normalizePathForCompare(pathValue);
|
||||||
|
const compareCwd = normalizePathForCompare(normalizedCwdBase);
|
||||||
|
const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`;
|
||||||
|
const comparePrefix = normalizePathForCompare(prefix);
|
||||||
|
|
||||||
|
if (comparePath === compareCwd) {
|
||||||
|
return ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparePath.startsWith(comparePrefix)) {
|
||||||
|
return pathValue.slice(prefix.length) || ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathForCompare(value: string): string {
|
||||||
|
return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsolutePath(value: string): boolean {
|
||||||
|
return value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user