Fix assistant file link resolution (#1025)
This commit is contained in:
@@ -418,11 +418,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
client={client}
|
client={client}
|
||||||
|
toast={toast}
|
||||||
spacing={spacing}
|
spacing={spacing}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
|
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderThoughtItem = useCallback(
|
const renderThoughtItem = useCallback(
|
||||||
|
|||||||
@@ -68,13 +68,11 @@ import type { AgentAttachment } from "@server/shared/messages";
|
|||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import {
|
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
|
||||||
parseAssistantFileLink,
|
|
||||||
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 { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
||||||
|
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
||||||
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import {
|
import {
|
||||||
getAssistantImageLoadStateFromMetadata,
|
getAssistantImageLoadStateFromMetadata,
|
||||||
@@ -545,6 +543,7 @@ interface AssistantMessageProps {
|
|||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
client?: DaemonClient | null;
|
client?: DaemonClient | null;
|
||||||
|
toast?: ToastApi | null;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||||
}
|
}
|
||||||
@@ -877,19 +876,26 @@ function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownLink({
|
function MarkdownLink({
|
||||||
href,
|
source,
|
||||||
style,
|
style,
|
||||||
onPress,
|
onPress,
|
||||||
|
onPrefetch,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
source: AssistantFileLinkSource;
|
||||||
style: StyleProp<TextStyle>;
|
style: StyleProp<TextStyle>;
|
||||||
onPress: (url: string) => void;
|
onPress: (source: AssistantFileLinkSource) => void;
|
||||||
|
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const handlePress = useCallback(() => onPress(href), [onPress, href]);
|
const href = source.href;
|
||||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
const handlePress = useCallback(() => onPress(source), [onPress, source]);
|
||||||
|
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
||||||
|
const handleHoverIn = useCallback(() => {
|
||||||
|
setHovered(true);
|
||||||
|
handlePrefetch();
|
||||||
|
}, [handlePrefetch]);
|
||||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||||
@@ -913,6 +919,7 @@ function MarkdownLink({
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
|
onFocus={handlePrefetch}
|
||||||
onHoverIn={handleHoverIn}
|
onHoverIn={handleHoverIn}
|
||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
>
|
>
|
||||||
@@ -941,11 +948,13 @@ function getInlineCodeAutoLinkUrl(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches: Array<{
|
const matches:
|
||||||
index: number;
|
| {
|
||||||
lastIndex: number;
|
index: number;
|
||||||
url: string;
|
lastIndex: number;
|
||||||
}> | null = markdownParser.linkify.match(trimmed);
|
url: string;
|
||||||
|
}[]
|
||||||
|
| null = markdownParser.linkify.match(trimmed);
|
||||||
if (!matches || matches.length !== 1) {
|
if (!matches || matches.length !== 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -958,6 +967,40 @@ function getInlineCodeAutoLinkUrl(
|
|||||||
return match.url;
|
return match.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInlineCodeAutoLinkSource(input: {
|
||||||
|
href: string;
|
||||||
|
content: string;
|
||||||
|
}): AssistantFileLinkSource {
|
||||||
|
return {
|
||||||
|
href: input.href,
|
||||||
|
text: input.content,
|
||||||
|
markup: "linkify",
|
||||||
|
sourceInfo: "auto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantMarkdownAstNode extends ASTNode {
|
||||||
|
sourceInfo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLinkSource {
|
||||||
|
return {
|
||||||
|
href: typeof node.attributes?.href === "string" ? node.attributes.href : "",
|
||||||
|
text: getMarkdownNodeText(node),
|
||||||
|
markup: node.markup,
|
||||||
|
sourceInfo: node.sourceInfo,
|
||||||
|
sourceType: node.sourceType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkdownNodeText(node: ASTNode): string {
|
||||||
|
if (!node.children.length) {
|
||||||
|
return node.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.children.map(getMarkdownNodeText).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function nodeHasParentType(parent: unknown, type: string): boolean {
|
function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||||
if (Array.isArray(parent)) {
|
if (Array.isArray(parent)) {
|
||||||
return parent.some((entry) => entry?.type === type);
|
return parent.some((entry) => entry?.type === type);
|
||||||
@@ -1394,20 +1437,22 @@ function MarkdownInheritedText({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MarkdownInheritedCodeLinkProps {
|
interface MarkdownInheritedCodeLinkProps {
|
||||||
href: string;
|
source: AssistantFileLinkSource;
|
||||||
inheritedStyles: TextStyle;
|
inheritedStyles: TextStyle;
|
||||||
codeInlineStyle: TextStyle;
|
codeInlineStyle: TextStyle;
|
||||||
linkStyle: TextStyle;
|
linkStyle: TextStyle;
|
||||||
onPress: (url: string) => boolean;
|
onPress: (source: AssistantFileLinkSource) => void;
|
||||||
|
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownInheritedCodeLink({
|
function MarkdownInheritedCodeLink({
|
||||||
href,
|
source,
|
||||||
inheritedStyles,
|
inheritedStyles,
|
||||||
codeInlineStyle,
|
codeInlineStyle,
|
||||||
linkStyle,
|
linkStyle,
|
||||||
onPress,
|
onPress,
|
||||||
|
onPrefetch,
|
||||||
children,
|
children,
|
||||||
}: MarkdownInheritedCodeLinkProps) {
|
}: MarkdownInheritedCodeLinkProps) {
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
@@ -1415,7 +1460,7 @@ function MarkdownInheritedCodeLink({
|
|||||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<MarkdownLink href={href} style={style} onPress={onPress}>
|
<MarkdownLink source={source} style={style} onPress={onPress} onPrefetch={onPrefetch}>
|
||||||
{children}
|
{children}
|
||||||
</MarkdownLink>
|
</MarkdownLink>
|
||||||
);
|
);
|
||||||
@@ -1452,6 +1497,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
|
toast,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
spacing = "default",
|
spacing = "default",
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
@@ -1472,20 +1518,34 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
return parser;
|
return parser;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLinkPress = useCallback(
|
const fileLinkResolver = useAssistantFileLinkResolver({
|
||||||
(url: string) => {
|
client,
|
||||||
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
|
serverId,
|
||||||
if (fileTarget) {
|
workspaceRoot,
|
||||||
onInlinePathPress?.(fileTarget);
|
onOpenWorkspaceFile: onInlinePathPress,
|
||||||
return false;
|
toast,
|
||||||
}
|
});
|
||||||
|
|
||||||
void openExternalUrl(url);
|
const handleLinkPress = useCallback(
|
||||||
|
(source: AssistantFileLinkSource) => {
|
||||||
|
fileLinkResolver.open({ source });
|
||||||
|
},
|
||||||
|
[fileLinkResolver],
|
||||||
|
);
|
||||||
|
const handleLinkPrefetch = useCallback(
|
||||||
|
(source: AssistantFileLinkSource) => {
|
||||||
|
fileLinkResolver.prefetch({ source });
|
||||||
|
},
|
||||||
|
[fileLinkResolver],
|
||||||
|
);
|
||||||
|
const handleMarkdownLinkPress = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
fileLinkResolver.open({ source: { href: 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, workspaceRoot],
|
[fileLinkResolver],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markdownRules = useMemo<RenderRules>(() => {
|
const markdownRules = useMemo<RenderRules>(() => {
|
||||||
@@ -1575,14 +1635,19 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
|
|
||||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||||
if (inlineCodeLinkUrl) {
|
if (inlineCodeLinkUrl) {
|
||||||
|
const source = getInlineCodeAutoLinkSource({
|
||||||
|
href: inlineCodeLinkUrl,
|
||||||
|
content,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<MarkdownInheritedCodeLink
|
<MarkdownInheritedCodeLink
|
||||||
key={node.key}
|
key={node.key}
|
||||||
href={inlineCodeLinkUrl}
|
source={source}
|
||||||
inheritedStyles={inheritedStyles}
|
inheritedStyles={inheritedStyles}
|
||||||
codeInlineStyle={styles.code_inline}
|
codeInlineStyle={styles.code_inline}
|
||||||
linkStyle={styles.link}
|
linkStyle={styles.link}
|
||||||
onPress={handleLinkPress}
|
onPress={handleLinkPress}
|
||||||
|
onPrefetch={handleLinkPrefetch}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</MarkdownInheritedCodeLink>
|
</MarkdownInheritedCodeLink>
|
||||||
@@ -1651,9 +1716,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||||
<MarkdownLink
|
<MarkdownLink
|
||||||
key={node.key}
|
key={node.key}
|
||||||
href={typeof node.attributes?.href === "string" ? node.attributes.href : ""}
|
source={getMarkdownLinkSource(node)}
|
||||||
style={styles.link}
|
style={styles.link}
|
||||||
onPress={handleLinkPress}
|
onPress={handleLinkPress}
|
||||||
|
onPrefetch={handleLinkPrefetch}
|
||||||
>
|
>
|
||||||
{Children.map(children, (child) => {
|
{Children.map(children, (child) => {
|
||||||
if (!isValidElement(child)) return child;
|
if (!isValidElement(child)) return child;
|
||||||
@@ -1692,7 +1758,15 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
|
}, [
|
||||||
|
client,
|
||||||
|
handleLinkPrefetch,
|
||||||
|
handleLinkPress,
|
||||||
|
markdownParser,
|
||||||
|
onInlinePathPress,
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
]);
|
||||||
|
|
||||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||||
const keyedBlocks = useMemo(
|
const keyedBlocks = useMemo(
|
||||||
@@ -1724,7 +1798,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
text={block}
|
text={block}
|
||||||
rules={markdownRules}
|
rules={markdownRules}
|
||||||
parser={markdownParser}
|
parser={markdownParser}
|
||||||
onLinkPress={handleLinkPress}
|
onLinkPress={handleMarkdownLinkPress}
|
||||||
/>
|
/>
|
||||||
</AssistantMessageBlockContainer>
|
</AssistantMessageBlockContainer>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
|
import {
|
||||||
|
createAssistantFileLinkResolver,
|
||||||
|
type AssistantFileLinkContext,
|
||||||
|
type AssistantFileLinkInteraction,
|
||||||
|
} from "@/utils/assistant-file-link-resolver";
|
||||||
|
import type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
|
export interface UseAssistantFileLinkResolverOptions {
|
||||||
|
client?: DaemonClient | null;
|
||||||
|
serverId?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
onOpenWorkspaceFile?: (target: InlinePathTarget) => void;
|
||||||
|
toast?: ToastApi | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkActions {
|
||||||
|
prefetch(input: Omit<AssistantFileLinkInteraction, "context">): void;
|
||||||
|
open(input: Omit<AssistantFileLinkInteraction, "context">): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssistantFileLinkResolver({
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
onOpenWorkspaceFile,
|
||||||
|
toast,
|
||||||
|
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
|
||||||
|
const context: AssistantFileLinkContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
}),
|
||||||
|
[serverId, workspaceRoot],
|
||||||
|
);
|
||||||
|
const latestContextRef = useRef(context);
|
||||||
|
latestContextRef.current = context;
|
||||||
|
|
||||||
|
const resolver = useMemo(
|
||||||
|
() =>
|
||||||
|
createAssistantFileLinkResolver({
|
||||||
|
async getDirectorySuggestions(input) {
|
||||||
|
if (!client) {
|
||||||
|
return { entries: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.getDirectorySuggestions(input);
|
||||||
|
return {
|
||||||
|
entries: result.entries,
|
||||||
|
error: result.error,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
openWorkspaceFile(target) {
|
||||||
|
onOpenWorkspaceFile?.(target);
|
||||||
|
},
|
||||||
|
openExternalUrl,
|
||||||
|
onUnresolvedFileCandidate(token) {
|
||||||
|
toast?.show(`No file found for ${token}`, {
|
||||||
|
variant: "error",
|
||||||
|
testID: "assistant-file-link-not-found-toast",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isCurrentContext(candidate) {
|
||||||
|
const current = latestContextRef.current;
|
||||||
|
return (
|
||||||
|
current.serverId === candidate.serverId &&
|
||||||
|
current.workspaceRoot === candidate.workspaceRoot
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[client, onOpenWorkspaceFile, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
prefetch(input) {
|
||||||
|
void resolver.prefetch({ ...input, context });
|
||||||
|
},
|
||||||
|
open(input) {
|
||||||
|
void resolver.open({ ...input, context });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[context, resolver],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createAssistantFileLinkResolver,
|
||||||
|
getAssistantFileLinkToken,
|
||||||
|
type AssistantFileLinkContext,
|
||||||
|
type DirectorySuggestionResult,
|
||||||
|
} from "./assistant-file-link-resolver";
|
||||||
|
|
||||||
|
const CONTEXT: AssistantFileLinkContext = {
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvedSuggestions(
|
||||||
|
entries: DirectorySuggestionResult["entries"],
|
||||||
|
): DirectorySuggestionResult {
|
||||||
|
return { entries, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
reject = promiseReject;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant file link resolver", () => {
|
||||||
|
it("dedupes in-flight prefetches and serves the click from cache", async () => {
|
||||||
|
const suggestions = vi.fn(async () =>
|
||||||
|
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
|
||||||
|
);
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl,
|
||||||
|
});
|
||||||
|
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
resolver.prefetch({ context: CONTEXT, source }),
|
||||||
|
resolver.prefetch({ context: CONTEXT, source }),
|
||||||
|
]);
|
||||||
|
const result = await resolver.open({ context: CONTEXT, source });
|
||||||
|
|
||||||
|
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(suggestions).toHaveBeenCalledWith({
|
||||||
|
query: "dumm.md",
|
||||||
|
cwd: "/Users/test/project",
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: false,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/src/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||||
|
expect(result.opened).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click consumes an in-flight hover resolution", async () => {
|
||||||
|
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||||
|
const suggestions = vi.fn(() => deferred.promise);
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl: vi.fn(),
|
||||||
|
});
|
||||||
|
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
|
||||||
|
|
||||||
|
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
||||||
|
const opened = resolver.open({ context: CONTEXT, source });
|
||||||
|
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
|
||||||
|
await prefetch;
|
||||||
|
const result = await opened;
|
||||||
|
|
||||||
|
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/docs/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
expect(result.opened).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a click after hover prefetch fails to query suggestions", async () => {
|
||||||
|
const suggestions = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl,
|
||||||
|
});
|
||||||
|
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||||
|
|
||||||
|
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
|
||||||
|
const openResult = await resolver.open({ context: CONTEXT, source });
|
||||||
|
|
||||||
|
expect(prefetchResult).toEqual({
|
||||||
|
kind: "unresolvedFileCandidate",
|
||||||
|
token: "dumm.md",
|
||||||
|
});
|
||||||
|
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||||
|
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/docs/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||||
|
expect(openResult.opened).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache unresolved candidates", async () => {
|
||||||
|
const suggestions = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const onUnresolvedFileCandidate = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl,
|
||||||
|
onUnresolvedFileCandidate,
|
||||||
|
});
|
||||||
|
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||||
|
|
||||||
|
const first = await resolver.open({ context: CONTEXT, source });
|
||||||
|
const second = await resolver.open({ context: CONTEXT, source });
|
||||||
|
|
||||||
|
expect(first).toEqual({
|
||||||
|
kind: "unresolvedFileCandidate",
|
||||||
|
token: "dumm.md",
|
||||||
|
opened: false,
|
||||||
|
});
|
||||||
|
expect(second.opened).toBe(true);
|
||||||
|
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||||
|
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/docs/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||||
|
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys cache entries by server, workspace, and token", async () => {
|
||||||
|
const suggestions = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl: vi.fn(),
|
||||||
|
});
|
||||||
|
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||||
|
|
||||||
|
await resolver.open({ context: CONTEXT, source });
|
||||||
|
await resolver.open({
|
||||||
|
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||||
|
expect(openWorkspaceFile).toHaveBeenLastCalledWith({
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/other/two/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply stale async results after the active context changes", async () => {
|
||||||
|
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||||
|
let isCurrent = true;
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: vi.fn(() => deferred.promise),
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl: vi.fn(),
|
||||||
|
isCurrentContext: () => isCurrent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opened = resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||||
|
});
|
||||||
|
isCurrent = false;
|
||||||
|
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
|
||||||
|
const result = await opened;
|
||||||
|
|
||||||
|
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||||
|
expect(result.opened).toBe(false);
|
||||||
|
expect(result.kind).toBe("file");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens direct workspace file links without querying suggestions", async () => {
|
||||||
|
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "src/components/message.tsx#L33" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(suggestions).not.toHaveBeenCalled();
|
||||||
|
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||||
|
raw: "src/components/message.tsx#L33",
|
||||||
|
path: "/Users/test/project/src/components/message.tsx",
|
||||||
|
lineStart: 33,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
expect(result.opened).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit external URLs external", async () => {
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||||
|
openWorkspaceFile: vi.fn(),
|
||||||
|
openExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://dumm.md", text: "dumm.md" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "external",
|
||||||
|
url: "http://dumm.md",
|
||||||
|
opened: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps auto-linkified normal domains external", async () => {
|
||||||
|
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile: vi.fn(),
|
||||||
|
openExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(suggestions).not.toHaveBeenCalled();
|
||||||
|
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "external",
|
||||||
|
url: "http://google.com",
|
||||||
|
opened: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps auto-linkified normal domain paths external", async () => {
|
||||||
|
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: suggestions,
|
||||||
|
openWorkspaceFile: vi.fn(),
|
||||||
|
openExternalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(suggestions).not.toHaveBeenCalled();
|
||||||
|
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "external",
|
||||||
|
url: "http://openai.com/path",
|
||||||
|
opened: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open unresolved linkified markdown filenames in the browser", async () => {
|
||||||
|
const openWorkspaceFile = vi.fn();
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const onUnresolvedFileCandidate = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||||
|
openWorkspaceFile,
|
||||||
|
openExternalUrl,
|
||||||
|
onUnresolvedFileCandidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefetchResult = await resolver.prefetch({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||||
|
});
|
||||||
|
const result = await resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||||
|
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||||
|
expect(prefetchResult).toEqual({
|
||||||
|
kind: "unresolvedFileCandidate",
|
||||||
|
token: "dumm.md",
|
||||||
|
});
|
||||||
|
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "unresolvedFileCandidate",
|
||||||
|
token: "dumm.md",
|
||||||
|
opened: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps failed ambiguous resolution out of the browser", async () => {
|
||||||
|
const openExternalUrl = vi.fn();
|
||||||
|
const onUnresolvedFileCandidate = vi.fn();
|
||||||
|
const resolver = createAssistantFileLinkResolver({
|
||||||
|
getDirectorySuggestions: vi.fn(async () => {
|
||||||
|
throw new Error("daemon unavailable");
|
||||||
|
}),
|
||||||
|
openWorkspaceFile: vi.fn(),
|
||||||
|
openExternalUrl,
|
||||||
|
onUnresolvedFileCandidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolver.open({
|
||||||
|
context: CONTEXT,
|
||||||
|
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||||
|
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "unresolvedFileCandidate",
|
||||||
|
token: "dumm.md",
|
||||||
|
opened: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||||
|
expect(
|
||||||
|
getAssistantFileLinkToken({
|
||||||
|
href: "http://dumm.md",
|
||||||
|
text: "dumm.md",
|
||||||
|
markup: "linkify",
|
||||||
|
sourceInfo: "auto",
|
||||||
|
}),
|
||||||
|
).toBe("dumm.md");
|
||||||
|
expect(
|
||||||
|
getAssistantFileLinkToken({
|
||||||
|
href: "http://google.com",
|
||||||
|
text: "google.com",
|
||||||
|
markup: "linkify",
|
||||||
|
sourceInfo: "auto",
|
||||||
|
}),
|
||||||
|
).toBe("http://google.com");
|
||||||
|
expect(
|
||||||
|
getAssistantFileLinkToken({
|
||||||
|
href: "http://dumm.md",
|
||||||
|
text: "dumm.md",
|
||||||
|
markup: "",
|
||||||
|
sourceInfo: "",
|
||||||
|
}),
|
||||||
|
).toBe("http://dumm.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import {
|
||||||
|
classifyAssistantFileLink,
|
||||||
|
isFileLookingAssistantToken,
|
||||||
|
type AssistantFileLinkClassification,
|
||||||
|
type InlinePathTarget,
|
||||||
|
} from "./inline-path";
|
||||||
|
|
||||||
|
export interface AssistantFileLinkContext {
|
||||||
|
serverId?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkSource {
|
||||||
|
href: string;
|
||||||
|
text?: string;
|
||||||
|
markup?: string;
|
||||||
|
sourceInfo?: string;
|
||||||
|
sourceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectorySuggestionEntry {
|
||||||
|
path: string;
|
||||||
|
kind: "file" | "directory";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectorySuggestionResult {
|
||||||
|
entries: DirectorySuggestionEntry[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkResolverDependencies {
|
||||||
|
getDirectorySuggestions: (input: {
|
||||||
|
query: string;
|
||||||
|
cwd: string;
|
||||||
|
includeFiles: true;
|
||||||
|
includeDirectories: false;
|
||||||
|
limit: number;
|
||||||
|
}) => Promise<DirectorySuggestionResult>;
|
||||||
|
openWorkspaceFile: (target: InlinePathTarget) => void;
|
||||||
|
openExternalUrl: (url: string) => void | Promise<void>;
|
||||||
|
onUnresolvedFileCandidate?: (token: string) => void;
|
||||||
|
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkResolver {
|
||||||
|
prefetch(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink>;
|
||||||
|
open(input: AssistantFileLinkInteraction): Promise<AssistantFileLinkOpenResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkInteraction {
|
||||||
|
context: AssistantFileLinkContext;
|
||||||
|
source: AssistantFileLinkSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedAssistantFileLink =
|
||||||
|
| {
|
||||||
|
kind: "external";
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "file";
|
||||||
|
target: InlinePathTarget;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "unresolvedFileCandidate";
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "ignored";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
|
||||||
|
opened: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
|
||||||
|
|
||||||
|
interface ParsedAssistantFileLinkInteraction {
|
||||||
|
token: string;
|
||||||
|
classification: AssistantFileLinkClassification;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAssistantFileLinkResolver(
|
||||||
|
dependencies: AssistantFileLinkResolverDependencies,
|
||||||
|
): AssistantFileLinkResolver {
|
||||||
|
const cache = new Map<string, CachedAssistantFileLink>();
|
||||||
|
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
||||||
|
|
||||||
|
async function resolve(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink> {
|
||||||
|
const parsed = parseInteraction(input);
|
||||||
|
if (!parsed) {
|
||||||
|
return { kind: "ignored" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.classification.kind === "external") {
|
||||||
|
return { kind: "external", url: parsed.classification.raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.classification.kind === "directFile") {
|
||||||
|
return { kind: "file", target: parsed.classification.target };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getResolutionKey(input.context, parsed.token);
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = inFlight.get(key);
|
||||||
|
if (active) {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = resolveAmbiguousCandidate({
|
||||||
|
context: input.context,
|
||||||
|
token: parsed.token,
|
||||||
|
target: parsed.classification.target,
|
||||||
|
getDirectorySuggestions: dependencies.getDirectorySuggestions,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (result.kind === "file") {
|
||||||
|
cache.set(key, result);
|
||||||
|
}
|
||||||
|
inFlight.delete(key);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch((): CachedAssistantFileLink => {
|
||||||
|
inFlight.delete(key);
|
||||||
|
return { kind: "unresolvedFileCandidate", token: parsed.token };
|
||||||
|
});
|
||||||
|
|
||||||
|
inFlight.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefetch(input) {
|
||||||
|
return resolve(input);
|
||||||
|
},
|
||||||
|
async open(input) {
|
||||||
|
const resolved = await resolve(input);
|
||||||
|
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
|
||||||
|
return { ...resolved, opened: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.kind === "file") {
|
||||||
|
dependencies.openWorkspaceFile(resolved.target);
|
||||||
|
return { ...resolved, opened: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.kind === "external") {
|
||||||
|
await dependencies.openExternalUrl(resolved.url);
|
||||||
|
return { ...resolved, opened: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.kind === "unresolvedFileCandidate") {
|
||||||
|
dependencies.onUnresolvedFileCandidate?.(resolved.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...resolved, opened: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||||
|
if (isLinkifiedSource(source)) {
|
||||||
|
const text = source.text?.trim();
|
||||||
|
if (text && isFileLookingAssistantToken(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInteraction(
|
||||||
|
input: AssistantFileLinkInteraction,
|
||||||
|
): ParsedAssistantFileLinkInteraction | null {
|
||||||
|
const token = getAssistantFileLinkToken(input.source).trim();
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classification = classifyAssistantFileLink(token, {
|
||||||
|
workspaceRoot: input.context.workspaceRoot,
|
||||||
|
});
|
||||||
|
if (!classification) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, classification };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAmbiguousCandidate(input: {
|
||||||
|
context: AssistantFileLinkContext;
|
||||||
|
token: string;
|
||||||
|
target: InlinePathTarget;
|
||||||
|
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
|
||||||
|
}): Promise<CachedAssistantFileLink> {
|
||||||
|
const workspaceRoot = input.context.workspaceRoot?.trim();
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
|
||||||
|
const suggestions = await input.getDirectorySuggestions({
|
||||||
|
query,
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: false,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||||
|
if (!match || suggestions.error) {
|
||||||
|
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "file",
|
||||||
|
target: {
|
||||||
|
...input.target,
|
||||||
|
path: joinWorkspacePath(workspaceRoot, match.path),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
|
||||||
|
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||||
|
const prefix = `${normalizedRoot}/`;
|
||||||
|
if (normalizedPath.startsWith(prefix)) {
|
||||||
|
return normalizedPath.slice(prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSlash = normalizedPath.lastIndexOf("/");
|
||||||
|
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
|
||||||
|
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||||
|
return source.markup === "linkify" || source.sourceInfo === "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canApplyResult(
|
||||||
|
context: AssistantFileLinkContext,
|
||||||
|
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
|
||||||
|
): boolean {
|
||||||
|
return isCurrentContext ? isCurrentContext(context) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
||||||
|
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
|
return root ? `${root}/${child}` : child;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
classifyAssistantFileLink,
|
||||||
normalizeInlinePathTarget,
|
normalizeInlinePathTarget,
|
||||||
parseAssistantFileLink,
|
parseAssistantFileLink,
|
||||||
parseFileProtocolUrl,
|
parseFileProtocolUrl,
|
||||||
@@ -70,7 +71,143 @@ describe("parseFileProtocolUrl", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("classifyAssistantFileLink", () => {
|
||||||
|
it("keeps explicit external URLs out of file parsing", () => {
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("http://dumm.md", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "external",
|
||||||
|
raw: "http://dumm.md",
|
||||||
|
});
|
||||||
|
expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
|
||||||
|
kind: "external",
|
||||||
|
raw: "mailto:test@example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies bare workspace candidates separately from direct relative files", () => {
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("dumm.md", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "ambiguousFileCandidate",
|
||||||
|
target: {
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("message-renderer.tsx", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "ambiguousFileCandidate",
|
||||||
|
target: {
|
||||||
|
raw: "message-renderer.tsx",
|
||||||
|
path: "/Users/test/project/message-renderer.tsx",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("src/components/message.tsx#L33", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "directFile",
|
||||||
|
target: {
|
||||||
|
raw: "src/components/message.tsx#L33",
|
||||||
|
path: "/Users/test/project/src/components/message.tsx",
|
||||||
|
lineStart: 33,
|
||||||
|
lineEnd: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not classify normal bare domains as file candidates", () => {
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("google.com", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("example.com", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
classifyAssistantFileLink("openai.com/path", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseAssistantFileLink", () => {
|
describe("parseAssistantFileLink", () => {
|
||||||
|
it("resolves bare markdown filenames against the active workspace", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("dumm.md", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves bare source filenames with line suffixes against the active workspace", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("file.ts:12", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
raw: "file.ts:12",
|
||||||
|
path: "/Users/test/project/file.ts",
|
||||||
|
lineStart: 12,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects bare domains and domain-like paths", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("google.com", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("google.com:80", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("openai.com/path", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves relative paths against the active workspace", () => {
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("src/components/message.tsx#L33", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
raw: "src/components/message.tsx#L33",
|
||||||
|
path: "/Users/test/project/src/components/message.tsx",
|
||||||
|
lineStart: 33,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
it("parses absolute POSIX hrefs inside the active workspace", () => {
|
||||||
expect(
|
expect(
|
||||||
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
|
||||||
@@ -146,6 +283,11 @@ describe("parseAssistantFileLink", () => {
|
|||||||
|
|
||||||
it("rejects external URLs", () => {
|
it("rejects external URLs", () => {
|
||||||
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
|
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseAssistantFileLink("http://dumm.md", {
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid line fragments", () => {
|
it("rejects invalid line fragments", () => {
|
||||||
|
|||||||
@@ -9,11 +9,82 @@ export interface InlinePathTarget {
|
|||||||
|
|
||||||
const FILE_PROTOCOL = "file:";
|
const FILE_PROTOCOL = "file:";
|
||||||
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
|
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
|
||||||
|
const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||||
|
"astro",
|
||||||
|
"bash",
|
||||||
|
"c",
|
||||||
|
"cc",
|
||||||
|
"cjs",
|
||||||
|
"cpp",
|
||||||
|
"cs",
|
||||||
|
"css",
|
||||||
|
"cts",
|
||||||
|
"cxx",
|
||||||
|
"env",
|
||||||
|
"fish",
|
||||||
|
"go",
|
||||||
|
"gql",
|
||||||
|
"gradle",
|
||||||
|
"graphql",
|
||||||
|
"h",
|
||||||
|
"hpp",
|
||||||
|
"htm",
|
||||||
|
"html",
|
||||||
|
"ini",
|
||||||
|
"java",
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"jsx",
|
||||||
|
"kt",
|
||||||
|
"kts",
|
||||||
|
"less",
|
||||||
|
"lock",
|
||||||
|
"lua",
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"mjs",
|
||||||
|
"mts",
|
||||||
|
"php",
|
||||||
|
"proto",
|
||||||
|
"py",
|
||||||
|
"rb",
|
||||||
|
"rs",
|
||||||
|
"sass",
|
||||||
|
"scss",
|
||||||
|
"sh",
|
||||||
|
"sql",
|
||||||
|
"svelte",
|
||||||
|
"swift",
|
||||||
|
"toml",
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"txt",
|
||||||
|
"vue",
|
||||||
|
"xml",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"zsh",
|
||||||
|
]);
|
||||||
|
|
||||||
export interface AssistantHrefParseOptions {
|
export interface AssistantHrefParseOptions {
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AssistantFileLinkClassification =
|
||||||
|
| {
|
||||||
|
kind: "external";
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "directFile";
|
||||||
|
target: InlinePathTarget;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "ambiguousFileCandidate";
|
||||||
|
target: InlinePathTarget;
|
||||||
|
};
|
||||||
|
|
||||||
export interface NormalizedInlinePathTarget {
|
export interface NormalizedInlinePathTarget {
|
||||||
directory: string;
|
directory: string;
|
||||||
file?: string;
|
file?: string;
|
||||||
@@ -172,6 +243,41 @@ function parseAssistantInlinePathLink(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classifyAssistantFileLink(
|
||||||
|
value: string,
|
||||||
|
options: AssistantHrefParseOptions = {},
|
||||||
|
): AssistantFileLinkClassification | null {
|
||||||
|
const raw = value ?? "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternalHref(trimmed)) {
|
||||||
|
return {
|
||||||
|
kind: "external",
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = parseAssistantFileLink(trimmed, options);
|
||||||
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
|
||||||
|
return {
|
||||||
|
kind: "ambiguousFileCandidate",
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "directFile",
|
||||||
|
target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseAssistantFileLink(
|
export function parseAssistantFileLink(
|
||||||
value: string,
|
value: string,
|
||||||
options: AssistantHrefParseOptions = {},
|
options: AssistantHrefParseOptions = {},
|
||||||
@@ -186,7 +292,7 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.includes("://")) {
|
if (isExternalHref(trimmed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +322,13 @@ export function parseAssistantFileLink(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||||
|
workspaceRoot: options.workspaceRoot,
|
||||||
|
});
|
||||||
|
if (relativeTarget) {
|
||||||
|
return relativeTarget;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAbsolutePath(trimmed)) {
|
if (!isAbsolutePath(trimmed)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -248,6 +361,91 @@ export function parseAssistantFileLink(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFileLookingAssistantToken(value: string): boolean {
|
||||||
|
const normalized = normalizePathToken(value);
|
||||||
|
if (!normalized || normalized.includes("?") || normalized.includes("://")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getHeuristicLocalPath(normalized);
|
||||||
|
if (!path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlausibleAssistantLocalPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorkspaceRelativeFileLink(
|
||||||
|
value: string,
|
||||||
|
options: AssistantHrefParseOptions,
|
||||||
|
): InlinePathTarget | null {
|
||||||
|
const workspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseLocalPathParts(value);
|
||||||
|
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = resolveRelativePathUnderRoot(parsed.path, workspaceRoot);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: value,
|
||||||
|
path: normalizedPath,
|
||||||
|
...parsed.lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalPathParts(
|
||||||
|
value: string,
|
||||||
|
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
|
||||||
|
const normalized = normalizePathToken(value);
|
||||||
|
if (!normalized || normalized.includes("?")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = normalized.indexOf("#");
|
||||||
|
const beforeHash = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
|
||||||
|
const hash = hashIndex >= 0 ? normalized.slice(hashIndex) : "";
|
||||||
|
const fragmentLines = parseLineFragment(hash);
|
||||||
|
if (!fragmentLines) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||||
|
if (inlinePathTarget) {
|
||||||
|
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: inlinePathTarget.path,
|
||||||
|
lines: {
|
||||||
|
lineStart: inlinePathTarget.lineStart,
|
||||||
|
lineEnd: inlinePathTarget.lineEnd,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beforeHash || beforeHash.includes(":")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlausibleAssistantLocalPath(beforeHash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: beforeHash,
|
||||||
|
lines: fragmentLines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeInlinePathTarget(
|
export function normalizeInlinePathTarget(
|
||||||
rawPath: string,
|
rawPath: string,
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
@@ -308,6 +506,130 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
|
|||||||
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExternalHref(value: string): boolean {
|
||||||
|
if (value.includes("://")) {
|
||||||
|
return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlinePathTarget = parseInlinePathToken(value);
|
||||||
|
if (inlinePathTarget) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmbiguousWorkspaceCandidate(
|
||||||
|
value: string,
|
||||||
|
target: InlinePathTarget,
|
||||||
|
workspaceRoot?: string,
|
||||||
|
): boolean {
|
||||||
|
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||||
|
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseLocalPathParts(value);
|
||||||
|
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !parsed.path.includes("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeuristicLocalPath(value: string): string | null {
|
||||||
|
const hashIndex = value.indexOf("#");
|
||||||
|
const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
|
||||||
|
const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
|
||||||
|
if (!parseLineFragment(hash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||||
|
if (inlinePathTarget) {
|
||||||
|
return inlinePathTarget.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!beforeHash || beforeHash.includes(":")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return beforeHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlausibleAssistantLocalPath(pathValue: string): boolean {
|
||||||
|
const normalized = normalizePathToken(pathValue);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsolutePath(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitRelative =
|
||||||
|
normalized.startsWith("./") || normalized.startsWith("../") || normalized.startsWith("~/");
|
||||||
|
if (explicitRelative) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = normalized.split("/").filter(Boolean);
|
||||||
|
const firstSegment = segments[0];
|
||||||
|
if (!firstSegment) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length > 1) {
|
||||||
|
return !isDomainLikePathSegment(firstSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment.startsWith(".") && firstSegment.length > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDot = firstSegment.lastIndexOf(".");
|
||||||
|
if (lastDot < 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = firstSegment.slice(lastDot + 1).toLowerCase();
|
||||||
|
return ASSISTANT_FILE_EXTENSIONS.has(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDomainLikePathSegment(segment: string): boolean {
|
||||||
|
return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
|
||||||
|
const normalizedPath = normalizePathToken(pathValue);
|
||||||
|
if (!normalizedPath || isAbsolutePath(normalizedPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = workspaceRoot.replace(/\/+$/, "") || "/";
|
||||||
|
const pathSegments = normalizedPath.split("/");
|
||||||
|
const resolvedSegments: string[] = [];
|
||||||
|
for (const segment of pathSegments) {
|
||||||
|
if (!segment || segment === ".") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment === "..") {
|
||||||
|
if (resolvedSegments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
resolvedSegments.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolvedSegments.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSegments.length === 0) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root === "/" ? `/${resolvedSegments.join("/")}` : `${root}/${resolvedSegments.join("/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFileUrlPath(pathname: string): string | null {
|
function normalizeFileUrlPath(pathname: string): string | null {
|
||||||
if (!pathname) {
|
if (!pathname) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -227,6 +227,47 @@ describe("searchWorkspaceEntries", () => {
|
|||||||
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports fuzzy basename queries for nested workspace files", async () => {
|
||||||
|
writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), "");
|
||||||
|
|
||||||
|
const results = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: "msgrndr",
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{
|
||||||
|
path: "src/components/message-renderer.tsx",
|
||||||
|
kind: "file",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks fuzzy basename matches after exact, prefix, and substring matches", async () => {
|
||||||
|
writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr"), "");
|
||||||
|
writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr-panel.tsx"), "");
|
||||||
|
writeFileSync(path.join(workspaceDir, "src", "components", "use-msgrndr.ts"), "");
|
||||||
|
writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), "");
|
||||||
|
|
||||||
|
const results = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: "msgrndr",
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results.map((entry) => entry.path)).toEqual([
|
||||||
|
"src/components/msgrndr",
|
||||||
|
"src/components/msgrndr-panel.tsx",
|
||||||
|
"src/components/use-msgrndr.ts",
|
||||||
|
"src/components/message-renderer.tsx",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
// POSIX-only: creates and follows a symlink escape fixture.
|
// POSIX-only: creates and follows a symlink escape fixture.
|
||||||
it.skipIf(isWindows)(
|
it.skipIf(isWindows)(
|
||||||
"supports path-style queries and does not escape cwd through symlinks",
|
"supports path-style queries and does not escape cwd through symlinks",
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ const directoryListCache = new Map<string, DirectoryListCacheEntry>();
|
|||||||
const workspaceEntryListCache = new Map<string, WorkspaceEntryListCacheEntry>();
|
const workspaceEntryListCache = new Map<string, WorkspaceEntryListCacheEntry>();
|
||||||
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||||
|
const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER;
|
||||||
|
const NO_WORKSPACE_MATCH_TIER = 5;
|
||||||
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([
|
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"dist",
|
"dist",
|
||||||
@@ -127,6 +129,7 @@ interface RankedWorkspaceEntry {
|
|||||||
matchTier: number;
|
matchTier: number;
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
matchOffset: number;
|
matchOffset: number;
|
||||||
|
fuzzyScore: number;
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,18 +306,17 @@ async function searchWorkspaceWithinParentDirectory(input: {
|
|||||||
if (entry.kind === "file" && !input.includeFiles) {
|
if (entry.kind === "file" && !input.includeFiles) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
|
const rankedEntry = rankWorkspaceEntry({
|
||||||
|
absolutePath: entry.absolutePath,
|
||||||
|
kind: entry.kind,
|
||||||
|
workspaceRoot: input.workspaceRoot,
|
||||||
|
searchLower,
|
||||||
|
});
|
||||||
|
if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked.push(
|
ranked.push(rankedEntry);
|
||||||
rankWorkspaceEntry({
|
|
||||||
absolutePath: entry.absolutePath,
|
|
||||||
kind: entry.kind,
|
|
||||||
workspaceRoot: input.workspaceRoot,
|
|
||||||
searchLower,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit);
|
return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit);
|
||||||
@@ -374,23 +376,17 @@ async function searchWorkspaceAcrossTree(input: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = normalizeRelativePath(input.workspaceRoot, entry.absolutePath);
|
const rankedEntry = rankWorkspaceEntry({
|
||||||
if (
|
absolutePath: entry.absolutePath,
|
||||||
searchLower &&
|
kind: entry.kind,
|
||||||
!relativePath.toLowerCase().includes(searchLower) &&
|
workspaceRoot: input.workspaceRoot,
|
||||||
!entry.name.toLowerCase().includes(searchLower)
|
searchLower,
|
||||||
) {
|
});
|
||||||
|
if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked.push(
|
ranked.push(rankedEntry);
|
||||||
rankWorkspaceEntry({
|
|
||||||
absolutePath: entry.absolutePath,
|
|
||||||
kind: entry.kind,
|
|
||||||
workspaceRoot: input.workspaceRoot,
|
|
||||||
searchLower,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,6 +426,9 @@ function compareRankedWorkspaceEntries(
|
|||||||
if (left.matchOffset !== right.matchOffset) {
|
if (left.matchOffset !== right.matchOffset) {
|
||||||
return left.matchOffset - right.matchOffset;
|
return left.matchOffset - right.matchOffset;
|
||||||
}
|
}
|
||||||
|
if (left.fuzzyScore !== right.fuzzyScore) {
|
||||||
|
return left.fuzzyScore - right.fuzzyScore;
|
||||||
|
}
|
||||||
if (left.depth !== right.depth) {
|
if (left.depth !== right.depth) {
|
||||||
return left.depth - right.depth;
|
return left.depth - right.depth;
|
||||||
}
|
}
|
||||||
@@ -538,6 +537,7 @@ function rankWorkspaceEntry(input: {
|
|||||||
matchTier: 3,
|
matchTier: 3,
|
||||||
segmentIndex: NO_SEGMENT_INDEX,
|
segmentIndex: NO_SEGMENT_INDEX,
|
||||||
matchOffset: 0,
|
matchOffset: 0,
|
||||||
|
fuzzyScore: NO_FUZZY_SCORE,
|
||||||
depth,
|
depth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -551,7 +551,9 @@ function rankWorkspaceEntry(input: {
|
|||||||
segment.includes(searchLower),
|
segment.includes(searchLower),
|
||||||
);
|
);
|
||||||
const matchOffset = relativeLower.indexOf(searchLower);
|
const matchOffset = relativeLower.indexOf(searchLower);
|
||||||
let matchTier = 4;
|
const basename = segments.at(-1) ?? "";
|
||||||
|
const fuzzyScore = scoreFuzzySubsequence(searchLower, basename);
|
||||||
|
let matchTier = NO_WORKSPACE_MATCH_TIER;
|
||||||
let segmentIndex = NO_SEGMENT_INDEX;
|
let segmentIndex = NO_SEGMENT_INDEX;
|
||||||
|
|
||||||
if (exactSegmentIndex >= 0) {
|
if (exactSegmentIndex >= 0) {
|
||||||
@@ -565,6 +567,8 @@ function rankWorkspaceEntry(input: {
|
|||||||
segmentIndex = partialSegmentIndex;
|
segmentIndex = partialSegmentIndex;
|
||||||
} else if (relativeLower.startsWith(searchLower)) {
|
} else if (relativeLower.startsWith(searchLower)) {
|
||||||
matchTier = 3;
|
matchTier = 3;
|
||||||
|
} else if (fuzzyScore !== null) {
|
||||||
|
matchTier = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -573,10 +577,47 @@ function rankWorkspaceEntry(input: {
|
|||||||
matchTier,
|
matchTier,
|
||||||
segmentIndex,
|
segmentIndex,
|
||||||
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
|
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
|
||||||
|
fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE,
|
||||||
depth,
|
depth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreFuzzySubsequence(query: string, candidate: string): number | null {
|
||||||
|
if (!query) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryIndex = 0;
|
||||||
|
let firstMatchIndex = -1;
|
||||||
|
let previousMatchIndex = -1;
|
||||||
|
let gapScore = 0;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let candidateIndex = 0;
|
||||||
|
candidateIndex < candidate.length && queryIndex < query.length;
|
||||||
|
candidateIndex += 1
|
||||||
|
) {
|
||||||
|
if (candidate[candidateIndex] !== query[queryIndex]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstMatchIndex === -1) {
|
||||||
|
firstMatchIndex = candidateIndex;
|
||||||
|
}
|
||||||
|
if (previousMatchIndex >= 0) {
|
||||||
|
gapScore += candidateIndex - previousMatchIndex - 1;
|
||||||
|
}
|
||||||
|
previousMatchIndex = candidateIndex;
|
||||||
|
queryIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryIndex !== query.length || firstMatchIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstMatchIndex + gapScore;
|
||||||
|
}
|
||||||
|
|
||||||
function findSegmentMatchIndex(
|
function findSegmentMatchIndex(
|
||||||
segments: string[],
|
segments: string[],
|
||||||
predicate: (segment: string) => boolean,
|
predicate: (segment: string) => boolean,
|
||||||
|
|||||||
Reference in New Issue
Block a user