Fix inline code file matching

This commit is contained in:
Mohamed Boudra
2026-05-17 23:40:31 +07:00
parent b55aa042d4
commit 721f1ee8d3
11 changed files with 301 additions and 38 deletions
@@ -184,6 +184,33 @@ describe("classifyAssistantFileLink", () => {
}), }),
).toBeNull(); ).toBeNull();
}); });
it("does not classify plain inline code words or identifiers as file candidates", () => {
for (const value of [
"main",
"origin/main",
"1f7fc232b",
"25994904967",
"babysit main 1f7fc232b",
]) {
expect(
classifyAssistantFileLink(value, {
workspaceRoot: "/Users/test/project",
}),
).toBeNull();
}
});
it("does not classify shell commands containing path arguments as file candidates", () => {
expect(
classifyAssistantFileLink(
"npm run lint -- packages/app/src/stores/workspace-layout-actions.ts packages/app/src/stores/workspace-layout-store.ts packages/app/src/screens/workspace/workspace-screen.tsx",
{
workspaceRoot: "/Users/test/project",
},
),
).toBeNull();
});
}); });
describe("parseAssistantFileLink", () => { describe("parseAssistantFileLink", () => {
+24 -6
View File
@@ -266,6 +266,10 @@ export function classifyAssistantFileLink(
}; };
} }
if (/\s/.test(trimmed)) {
return null;
}
const target = parseAssistantFileLink(trimmed, options); const target = parseAssistantFileLink(trimmed, options);
if (!target) { if (!target) {
return null; return null;
@@ -369,7 +373,12 @@ export function parseAssistantFileLink(
export function isFileLookingAssistantToken(value: string): boolean { export function isFileLookingAssistantToken(value: string): boolean {
const normalized = normalizePathToken(value); const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?") || normalized.includes("://")) { if (
!normalized ||
/\s/.test(normalized) ||
normalized.includes("?") ||
normalized.includes("://")
) {
return false; return false;
} }
@@ -586,19 +595,28 @@ function isPlausibleAssistantLocalPath(pathValue: string): boolean {
} }
if (segments.length > 1) { if (segments.length > 1) {
return !isDomainLikePathSegment(firstSegment); const lastSegment = segments[segments.length - 1];
return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
} }
if (firstSegment.startsWith(".") && firstSegment.length > 1) { return isPlausibleAssistantFileName(firstSegment);
}
function isPlausibleAssistantFileName(fileName: string | undefined): boolean {
if (!fileName) {
return false;
}
if (fileName.startsWith(".") && fileName.length > 1) {
return true; return true;
} }
const lastDot = firstSegment.lastIndexOf("."); const lastDot = fileName.lastIndexOf(".");
if (lastDot < 0) { if (lastDot < 0) {
return true; return false;
} }
const extension = firstSegment.slice(lastDot + 1).toLowerCase(); const extension = fileName.slice(lastDot + 1).toLowerCase();
return ASSISTANT_FILE_EXTENSIONS.has(extension); return ASSISTANT_FILE_EXTENSIONS.has(extension);
} }
@@ -3,8 +3,11 @@ import {
createAssistantFileLinkResolver, createAssistantFileLinkResolver,
getAssistantFileLinkToken, getAssistantFileLinkToken,
type AssistantFileLinkContext, type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult, type DirectorySuggestionResult,
} from "./resolver"; } from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = { const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1", serverId: "server-1",
@@ -28,6 +31,55 @@ function createDeferred<T>() {
return { promise, resolve, reject }; return { promise, resolve, reject };
} }
interface DirectorySearch {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
class FakeWorkspaceFiles {
readonly searches: DirectorySearch[] = [];
readonly openedFiles: OpenedFile[] = [];
readonly unresolvedTokens: string[] = [];
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
});
}
private getDirectorySuggestions = async (
search: DirectorySearch,
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
private openWorkspaceFile = (
target: InlinePathTarget,
disposition: OpenFileDisposition,
): void => {
this.openedFiles.push({ target, disposition });
};
private onUnresolvedFileCandidate = (token: string): void => {
this.unresolvedTokens.push(token);
};
}
describe("assistant file link resolver", () => { describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => { it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () => const suggestions = vi.fn(async () =>
@@ -54,6 +106,7 @@ describe("assistant file link resolver", () => {
cwd: "/Users/test/project", cwd: "/Users/test/project",
includeFiles: true, includeFiles: true,
includeDirectories: false, includeDirectories: false,
matchMode: "suffix",
limit: 1, limit: 1,
}); });
expect(openWorkspaceFile).toHaveBeenCalledWith( expect(openWorkspaceFile).toHaveBeenCalledWith(
@@ -284,48 +337,79 @@ describe("assistant file link resolver", () => {
); );
}); });
it("resolves inline-code basename line refs through directory suggestions", async () => { it("opens basename line refs when the daemon returns that exact filename", async () => {
const suggestions = vi.fn(async () => const workspaceFiles = new FakeWorkspaceFiles({
resolvedSuggestions([ "file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
{ path: "packages/server/src/server/workspace-git-service.ts", kind: "file" },
]),
);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
}); });
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({ const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: { source: {
href: "workspace-git-service.ts:1553", href: "file.ts:12",
text: "workspace-git-service.ts:1553", text: "file.ts:12",
sourceType: "inline-code", sourceType: "inline-code",
}, },
disposition: "main", disposition: "main",
}); });
expect(suggestions).toHaveBeenCalledWith({ expect(workspaceFiles.searches).toEqual([
query: "workspace-git-service.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{ {
raw: "workspace-git-service.ts:1553", query: "file.ts",
path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts", cwd: "/Users/test/project",
lineStart: 1553, includeFiles: true,
lineEnd: undefined, includeDirectories: false,
matchMode: "suffix",
limit: 1,
}, },
"main", ]);
); expect(workspaceFiles.openedFiles).toEqual([
{
target: {
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
lineEnd: undefined,
},
disposition: "main",
},
]);
expect(result.opened).toBe(true); expect(result.opened).toBe(true);
}); });
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
const workspaceFiles = new FakeWorkspaceFiles({});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "src/file.ts",
text: "src/file.ts",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "src/file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([]);
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "src/file.ts",
opened: false,
});
});
it("passes side open disposition to workspace file links", async () => { it("passes side open disposition to workspace file links", async () => {
const openWorkspaceFile = vi.fn(); const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({ const resolver = createAssistantFileLinkResolver({
@@ -35,6 +35,7 @@ export interface AssistantFileLinkResolverDependencies {
cwd: string; cwd: string;
includeFiles: true; includeFiles: true;
includeDirectories: false; includeDirectories: false;
matchMode: "suffix";
limit: number; limit: number;
}) => Promise<DirectorySuggestionResult>; }) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
@@ -103,7 +104,15 @@ export function createAssistantFileLinkResolver(
return { kind: "external", url: parsed.classification.raw }; return { kind: "external", url: parsed.classification.raw };
} }
if (parsed.classification.kind === "directFile") { if (
parsed.classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context: input.context,
source: input.source,
token: parsed.token,
target: parsed.classification.target,
})
) {
return { kind: "file", target: parsed.classification.target }; return { kind: "file", target: parsed.classification.target };
} }
@@ -215,6 +224,7 @@ async function resolveAmbiguousCandidate(input: {
cwd: workspaceRoot, cwd: workspaceRoot,
includeFiles: true, includeFiles: true,
includeDirectories: false, includeDirectories: false,
matchMode: "suffix",
limit: 1, limit: 1,
}); });
const match = suggestions.entries.find((entry) => entry.kind === "file"); const match = suggestions.entries.find((entry) => entry.kind === "file");
@@ -243,6 +253,38 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath; return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
} }
function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
token: string;
target: InlinePathTarget;
}): boolean {
if (input.source.sourceType !== "inline-code") {
return false;
}
if (isAbsoluteInlineCodeToken(input.token)) {
return false;
}
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return false;
}
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = input.target.path.replace(/\\/g, "/");
return normalizedPath.startsWith(`${normalizedRoot}/`);
}
function isAbsoluteInlineCodeToken(token: string): boolean {
return (
token.startsWith("/") ||
token.toLowerCase().startsWith("file://") ||
/^[A-Za-z]:[\\/]/.test(token)
);
}
function getResolutionKey(context: AssistantFileLinkContext, token: string): string { function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0"); return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
} }
@@ -1497,6 +1497,7 @@ test("requests directory suggestions via RPC", async () => {
cwd: "/tmp/project", cwd: "/tmp/project",
includeFiles: true, includeFiles: true,
includeDirectories: true, includeDirectories: true,
matchMode: "suffix",
}, },
"req-directories", "req-directories",
); );
@@ -1508,6 +1509,7 @@ test("requests directory suggestions via RPC", async () => {
expect(request.cwd).toBe("/tmp/project"); expect(request.cwd).toBe("/tmp/project");
expect(request.includeFiles).toBe(true); expect(request.includeFiles).toBe(true);
expect(request.includeDirectories).toBe(true); expect(request.includeDirectories).toBe(true);
expect(request.matchMode).toBe("suffix");
expect(request.limit).toBe(10); expect(request.limit).toBe(10);
expect(request.requestId).toBe("req-directories"); expect(request.requestId).toBe("req-directories");
@@ -3090,6 +3090,7 @@ export class DaemonClient {
cwd?: string; cwd?: string;
includeFiles?: boolean; includeFiles?: boolean;
includeDirectories?: boolean; includeDirectories?: boolean;
matchMode?: "fuzzy" | "suffix";
}, },
requestId?: string, requestId?: string,
): Promise<DirectorySuggestionsPayload> { ): Promise<DirectorySuggestionsPayload> {
@@ -3101,6 +3102,7 @@ export class DaemonClient {
cwd: options.cwd, cwd: options.cwd,
includeFiles: options.includeFiles, includeFiles: options.includeFiles,
includeDirectories: options.includeDirectories, includeDirectories: options.includeDirectories,
matchMode: options.matchMode,
limit: options.limit, limit: options.limit,
}, },
responseType: "directory_suggestions_response", responseType: "directory_suggestions_response",
+2 -1
View File
@@ -4705,7 +4705,7 @@ export class Session {
} }
private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise<void> { private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise<void> {
const { query, limit, requestId, cwd, includeFiles, includeDirectories } = msg; const { query, limit, requestId, cwd, includeFiles, includeDirectories, matchMode } = msg;
try { try {
const workspaceCwd = cwd?.trim(); const workspaceCwd = cwd?.trim();
@@ -4716,6 +4716,7 @@ export class Session {
limit, limit,
includeFiles, includeFiles,
includeDirectories, includeDirectories,
matchMode,
}) })
: ( : (
await searchHomeDirectories({ await searchHomeDirectories({
@@ -330,6 +330,7 @@ describe("shared messages stream parsing", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
includeFiles: true, includeFiles: true,
includeDirectories: true, includeDirectories: true,
matchMode: "suffix",
limit: 20, limit: 20,
requestId: "req-dir-1", requestId: "req-dir-1",
}); });
+1
View File
@@ -1441,6 +1441,7 @@ export const DirectorySuggestionsRequestSchema = z.object({
cwd: z.string().optional(), cwd: z.string().optional(),
includeFiles: z.boolean().optional(), includeFiles: z.boolean().optional(),
includeDirectories: z.boolean().optional(), includeDirectories: z.boolean().optional(),
matchMode: z.enum(["fuzzy", "suffix"]).optional(),
limit: z.number().int().min(1).max(100).optional(), limit: z.number().int().min(1).max(100).optional(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -268,6 +268,39 @@ describe("searchWorkspaceEntries", () => {
]); ]);
}); });
it("suffix mode matches whole path segment suffixes without fuzzy matches", async () => {
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
writeFileSync(path.join(workspaceDir, "src", "file.ts"), "");
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "file.ts"), "");
writeFileSync(path.join(workspaceDir, "src", "paseo-config-file.ts"), "");
const basenameResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "file.ts",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
const suffixResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: "src/file.ts",
limit: 20,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(basenameResults).toEqual([
{ path: "src/file.ts", kind: "file" },
{ path: "packages/app/src/file.ts", kind: "file" },
]);
expect(suffixResults).toEqual([
{ path: "src/file.ts", kind: "file" },
{ path: "packages/app/src/file.ts", kind: "file" },
]);
});
// 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",
@@ -23,10 +23,13 @@ export interface SearchWorkspaceEntriesOptions {
limit?: number; limit?: number;
includeFiles?: boolean; includeFiles?: boolean;
includeDirectories?: boolean; includeDirectories?: boolean;
matchMode?: WorkspaceMatchMode;
maxDepth?: number; maxDepth?: number;
maxEntriesScanned?: number; maxEntriesScanned?: number;
} }
export type WorkspaceMatchMode = "fuzzy" | "suffix";
const DEFAULT_LIMIT = 30; const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100; const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_DEPTH = 6;
@@ -153,7 +156,8 @@ export async function searchWorkspaceEntries(
return []; return [];
} }
if (queryParts.isPathQuery) { const matchMode = options.matchMode ?? "fuzzy";
if (queryParts.isPathQuery && matchMode !== "suffix") {
return searchWorkspaceWithinParentDirectory({ return searchWorkspaceWithinParentDirectory({
workspaceRoot, workspaceRoot,
parentPart: queryParts.parentPart, parentPart: queryParts.parentPart,
@@ -164,12 +168,17 @@ export async function searchWorkspaceEntries(
}); });
} }
const searchTerm =
matchMode === "suffix"
? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/")
: queryParts.searchTerm;
return searchWorkspaceAcrossTree({ return searchWorkspaceAcrossTree({
workspaceRoot, workspaceRoot,
searchTerm: queryParts.searchTerm, searchTerm,
limit, limit,
includeDirectories, includeDirectories,
includeFiles, includeFiles,
matchMode,
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
}); });
@@ -328,6 +337,7 @@ async function searchWorkspaceAcrossTree(input: {
limit: number; limit: number;
includeDirectories: boolean; includeDirectories: boolean;
includeFiles: boolean; includeFiles: boolean;
matchMode: WorkspaceMatchMode;
maxDepth: number; maxDepth: number;
maxEntriesScanned: number; maxEntriesScanned: number;
}): Promise<WorkspaceSuggestionEntry[]> { }): Promise<WorkspaceSuggestionEntry[]> {
@@ -375,6 +385,16 @@ async function searchWorkspaceAcrossTree(input: {
if (entry.kind === "file" && !input.includeFiles) { if (entry.kind === "file" && !input.includeFiles) {
continue; continue;
} }
if (
input.matchMode === "suffix" &&
!workspaceEntryMatchesSuffixQuery({
absolutePath: entry.absolutePath,
workspaceRoot: input.workspaceRoot,
query: input.searchTerm,
})
) {
continue;
}
const rankedEntry = rankWorkspaceEntry({ const rankedEntry = rankWorkspaceEntry({
absolutePath: entry.absolutePath, absolutePath: entry.absolutePath,
@@ -382,7 +402,11 @@ async function searchWorkspaceAcrossTree(input: {
workspaceRoot: input.workspaceRoot, workspaceRoot: input.workspaceRoot,
searchLower, searchLower,
}); });
if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) { if (
input.matchMode !== "suffix" &&
searchLower &&
rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER
) {
continue; continue;
} }
@@ -393,6 +417,34 @@ async function searchWorkspaceAcrossTree(input: {
return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit); return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit);
} }
function workspaceEntryMatchesSuffixQuery(input: {
absolutePath: string;
workspaceRoot: string;
query: string;
}): boolean {
const querySegments = input.query
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "")
.split("/")
.filter(Boolean)
.map((segment) => segment.toLowerCase());
if (querySegments.length === 0) {
return false;
}
const pathSegments = normalizeRelativePath(input.workspaceRoot, input.absolutePath)
.split("/")
.filter(Boolean)
.map((segment) => segment.toLowerCase());
if (querySegments.length > pathSegments.length) {
return false;
}
const offset = pathSegments.length - querySegments.length;
return querySegments.every((segment, index) => pathSegments[offset + index] === segment);
}
function dedupeAndSortWorkspaceEntries( function dedupeAndSortWorkspaceEntries(
rankedEntries: RankedWorkspaceEntry[], rankedEntries: RankedWorkspaceEntry[],
): WorkspaceSuggestionEntry[] { ): WorkspaceSuggestionEntry[] {