From 7e255a52499ae63701026ca079764de8417cb97f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 14 May 2026 20:55:41 +0800 Subject: [PATCH] Fix assistant file link resolution (#1025) --- .../app/src/components/agent-stream-view.tsx | 3 +- packages/app/src/components/message.tsx | 140 +++++-- .../hooks/use-assistant-file-link-resolver.ts | 88 ++++ .../assistant-file-link-resolver.test.ts | 395 ++++++++++++++++++ .../src/utils/assistant-file-link-resolver.ts | 258 ++++++++++++ packages/app/src/utils/inline-path.test.ts | 142 +++++++ packages/app/src/utils/inline-path.ts | 324 +++++++++++++- .../src/utils/directory-suggestions.test.ts | 41 ++ .../server/src/utils/directory-suggestions.ts | 89 ++-- 9 files changed, 1421 insertions(+), 59 deletions(-) create mode 100644 packages/app/src/hooks/use-assistant-file-link-resolver.ts create mode 100644 packages/app/src/utils/assistant-file-link-resolver.test.ts create mode 100644 packages/app/src/utils/assistant-file-link-resolver.ts diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 7bd100e84..7de6bf523 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -418,11 +418,12 @@ const AgentStreamViewComponent = forwardRef ); }, - [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client], + [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8864a778a..95ca9e1b2 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -68,13 +68,11 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import { - parseAssistantFileLink, - parseInlinePathToken, - type InlinePathTarget, -} from "@/utils/inline-path"; +import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; 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 { getAssistantImageLoadStateFromMetadata, @@ -545,6 +543,7 @@ interface AssistantMessageProps { workspaceRoot?: string; serverId?: string; client?: DaemonClient | null; + toast?: ToastApi | null; disableOuterSpacing?: boolean; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; } @@ -877,19 +876,26 @@ function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) { } function MarkdownLink({ - href, + source, style, onPress, + onPrefetch, children, }: { - href: string; + source: AssistantFileLinkSource; style: StyleProp; - onPress: (url: string) => void; + onPress: (source: AssistantFileLinkSource) => void; + onPrefetch: (source: AssistantFileLinkSource) => void; children: ReactNode; }) { const [hovered, setHovered] = useState(false); - const handlePress = useCallback(() => onPress(href), [onPress, href]); - const handleHoverIn = useCallback(() => setHovered(true), []); + const href = source.href; + 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 hoveredTextStyle = useMemo>( () => [style, hovered && { textDecorationLine: "underline" as const }], @@ -913,6 +919,7 @@ function MarkdownLink({ @@ -941,11 +948,13 @@ function getInlineCodeAutoLinkUrl( return null; } - const matches: Array<{ - index: number; - lastIndex: number; - url: string; - }> | null = markdownParser.linkify.match(trimmed); + const matches: + | { + index: number; + lastIndex: number; + url: string; + }[] + | null = markdownParser.linkify.match(trimmed); if (!matches || matches.length !== 1) { return null; } @@ -958,6 +967,40 @@ function getInlineCodeAutoLinkUrl( 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 { if (Array.isArray(parent)) { return parent.some((entry) => entry?.type === type); @@ -1394,20 +1437,22 @@ function MarkdownInheritedText({ } interface MarkdownInheritedCodeLinkProps { - href: string; + source: AssistantFileLinkSource; inheritedStyles: TextStyle; codeInlineStyle: TextStyle; linkStyle: TextStyle; - onPress: (url: string) => boolean; + onPress: (source: AssistantFileLinkSource) => void; + onPrefetch: (source: AssistantFileLinkSource) => void; children: ReactNode; } function MarkdownInheritedCodeLink({ - href, + source, inheritedStyles, codeInlineStyle, linkStyle, onPress, + onPrefetch, children, }: MarkdownInheritedCodeLinkProps) { const style = useMemo( @@ -1415,7 +1460,7 @@ function MarkdownInheritedCodeLink({ [inheritedStyles, codeInlineStyle, linkStyle], ); return ( - + {children} ); @@ -1452,6 +1497,7 @@ export const AssistantMessage = memo(function AssistantMessage({ workspaceRoot, serverId, client, + toast, disableOuterSpacing, spacing = "default", }: AssistantMessageProps) { @@ -1472,20 +1518,34 @@ export const AssistantMessage = memo(function AssistantMessage({ return parser; }, []); - const handleLinkPress = useCallback( - (url: string) => { - const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null; - if (fileTarget) { - onInlinePathPress?.(fileTarget); - return false; - } + const fileLinkResolver = useAssistantFileLinkResolver({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile: onInlinePathPress, + 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. // We already handled it above, so return false to avoid duplicate opens. return false; }, - [onInlinePathPress, workspaceRoot], + [fileLinkResolver], ); const markdownRules = useMemo(() => { @@ -1575,14 +1635,19 @@ export const AssistantMessage = memo(function AssistantMessage({ const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content); if (inlineCodeLinkUrl) { + const source = getInlineCodeAutoLinkSource({ + href: inlineCodeLinkUrl, + content, + }); return ( {content} @@ -1651,9 +1716,10 @@ export const AssistantMessage = memo(function AssistantMessage({ link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => ( {Children.map(children, (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 keyedBlocks = useMemo( @@ -1724,7 +1798,7 @@ export const AssistantMessage = memo(function AssistantMessage({ text={block} rules={markdownRules} parser={markdownParser} - onLinkPress={handleLinkPress} + onLinkPress={handleMarkdownLinkPress} /> ))} diff --git a/packages/app/src/hooks/use-assistant-file-link-resolver.ts b/packages/app/src/hooks/use-assistant-file-link-resolver.ts new file mode 100644 index 000000000..bad89efbf --- /dev/null +++ b/packages/app/src/hooks/use-assistant-file-link-resolver.ts @@ -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): void; + open(input: Omit): 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], + ); +} diff --git a/packages/app/src/utils/assistant-file-link-resolver.test.ts b/packages/app/src/utils/assistant-file-link-resolver.test.ts new file mode 100644 index 000000000..2e279d84d --- /dev/null +++ b/packages/app/src/utils/assistant-file-link-resolver.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((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(); + 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(); + 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"); + }); +}); diff --git a/packages/app/src/utils/assistant-file-link-resolver.ts b/packages/app/src/utils/assistant-file-link-resolver.ts new file mode 100644 index 000000000..ccde952b7 --- /dev/null +++ b/packages/app/src/utils/assistant-file-link-resolver.ts @@ -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; + openWorkspaceFile: (target: InlinePathTarget) => void; + openExternalUrl: (url: string) => void | Promise; + onUnresolvedFileCandidate?: (token: string) => void; + isCurrentContext?: (context: AssistantFileLinkContext) => boolean; +} + +export interface AssistantFileLinkResolver { + prefetch(input: AssistantFileLinkInteraction): Promise; + open(input: AssistantFileLinkInteraction): Promise; +} + +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; + +interface ParsedAssistantFileLinkInteraction { + token: string; + classification: AssistantFileLinkClassification; +} + +export function createAssistantFileLinkResolver( + dependencies: AssistantFileLinkResolverDependencies, +): AssistantFileLinkResolver { + const cache = new Map(); + const inFlight = new Map>(); + + async function resolve(input: AssistantFileLinkInteraction): Promise { + 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 { + 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; +} diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/utils/inline-path.test.ts index a641a897d..866bece0f 100644 --- a/packages/app/src/utils/inline-path.test.ts +++ b/packages/app/src/utils/inline-path.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + classifyAssistantFileLink, normalizeInlinePathTarget, parseAssistantFileLink, 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", () => { + 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", () => { expect( parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", { @@ -146,6 +283,11 @@ describe("parseAssistantFileLink", () => { it("rejects external URLs", () => { 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", () => { diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts index fcdd506db..c32c5bc3e 100644 --- a/packages/app/src/utils/inline-path.ts +++ b/packages/app/src/utils/inline-path.ts @@ -9,11 +9,82 @@ export interface InlinePathTarget { const FILE_PROTOCOL = "file:"; 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 { workspaceRoot?: string; } +export type AssistantFileLinkClassification = + | { + kind: "external"; + raw: string; + } + | { + kind: "directFile"; + target: InlinePathTarget; + } + | { + kind: "ambiguousFileCandidate"; + target: InlinePathTarget; + }; + export interface NormalizedInlinePathTarget { directory: 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( value: string, options: AssistantHrefParseOptions = {}, @@ -186,7 +292,7 @@ export function parseAssistantFileLink( return null; } - if (trimmed.includes("://")) { + if (isExternalHref(trimmed)) { return null; } @@ -216,6 +322,13 @@ export function parseAssistantFileLink( }; } + const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { + workspaceRoot: options.workspaceRoot, + }); + if (relativeTarget) { + return relativeTarget; + } + if (!isAbsolutePath(trimmed)) { 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 } | 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( rawPath: string, cwd?: string, @@ -308,6 +506,130 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole 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 { if (!pathname) { return null; diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index cef2928a0..2e62fe0a8 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -227,6 +227,47 @@ describe("searchWorkspaceEntries", () => { 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. it.skipIf(isWindows)( "supports path-style queries and does not escape cwd through symlinks", diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index f89279362..32f016679 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -73,6 +73,8 @@ const directoryListCache = new Map(); const workspaceEntryListCache = new Map(); const NO_SEGMENT_INDEX = 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([ "node_modules", "dist", @@ -127,6 +129,7 @@ interface RankedWorkspaceEntry { matchTier: number; segmentIndex: number; matchOffset: number; + fuzzyScore: number; depth: number; } @@ -303,18 +306,17 @@ async function searchWorkspaceWithinParentDirectory(input: { if (entry.kind === "file" && !input.includeFiles) { 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; } - ranked.push( - rankWorkspaceEntry({ - absolutePath: entry.absolutePath, - kind: entry.kind, - workspaceRoot: input.workspaceRoot, - searchLower, - }), - ); + ranked.push(rankedEntry); } return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit); @@ -374,23 +376,17 @@ async function searchWorkspaceAcrossTree(input: { continue; } - const relativePath = normalizeRelativePath(input.workspaceRoot, entry.absolutePath); - if ( - searchLower && - !relativePath.toLowerCase().includes(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; } - ranked.push( - rankWorkspaceEntry({ - absolutePath: entry.absolutePath, - kind: entry.kind, - workspaceRoot: input.workspaceRoot, - searchLower, - }), - ); + ranked.push(rankedEntry); } } @@ -430,6 +426,9 @@ function compareRankedWorkspaceEntries( if (left.matchOffset !== right.matchOffset) { return left.matchOffset - right.matchOffset; } + if (left.fuzzyScore !== right.fuzzyScore) { + return left.fuzzyScore - right.fuzzyScore; + } if (left.depth !== right.depth) { return left.depth - right.depth; } @@ -538,6 +537,7 @@ function rankWorkspaceEntry(input: { matchTier: 3, segmentIndex: NO_SEGMENT_INDEX, matchOffset: 0, + fuzzyScore: NO_FUZZY_SCORE, depth, }; } @@ -551,7 +551,9 @@ function rankWorkspaceEntry(input: { segment.includes(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; if (exactSegmentIndex >= 0) { @@ -565,6 +567,8 @@ function rankWorkspaceEntry(input: { segmentIndex = partialSegmentIndex; } else if (relativeLower.startsWith(searchLower)) { matchTier = 3; + } else if (fuzzyScore !== null) { + matchTier = 4; } return { @@ -573,10 +577,47 @@ function rankWorkspaceEntry(input: { matchTier, segmentIndex, matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, + fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, 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( segments: string[], predicate: (segment: string) => boolean,