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();
});
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", () => {
+24 -6
View File
@@ -266,6 +266,10 @@ export function classifyAssistantFileLink(
};
}
if (/\s/.test(trimmed)) {
return null;
}
const target = parseAssistantFileLink(trimmed, options);
if (!target) {
return null;
@@ -369,7 +373,12 @@ export function parseAssistantFileLink(
export function isFileLookingAssistantToken(value: string): boolean {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?") || normalized.includes("://")) {
if (
!normalized ||
/\s/.test(normalized) ||
normalized.includes("?") ||
normalized.includes("://")
) {
return false;
}
@@ -586,19 +595,28 @@ function isPlausibleAssistantLocalPath(pathValue: string): boolean {
}
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;
}
const lastDot = firstSegment.lastIndexOf(".");
const lastDot = fileName.lastIndexOf(".");
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);
}
@@ -3,8 +3,11 @@ import {
createAssistantFileLinkResolver,
getAssistantFileLinkToken,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
} from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
@@ -28,6 +31,55 @@ function createDeferred<T>() {
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", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
@@ -54,6 +106,7 @@ describe("assistant file link resolver", () => {
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
@@ -284,48 +337,79 @@ describe("assistant file link resolver", () => {
);
});
it("resolves inline-code basename line refs through directory suggestions", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([
{ path: "packages/server/src/server/workspace-git-service.ts", kind: "file" },
]),
);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
it("opens basename line refs when the daemon returns that exact filename", async () => {
const workspaceFiles = new FakeWorkspaceFiles({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "workspace-git-service.ts:1553",
text: "workspace-git-service.ts:1553",
href: "file.ts:12",
text: "file.ts:12",
sourceType: "inline-code",
},
disposition: "main",
});
expect(suggestions).toHaveBeenCalledWith({
query: "workspace-git-service.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
expect(workspaceFiles.searches).toEqual([
{
raw: "workspace-git-service.ts:1553",
path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts",
lineStart: 1553,
lineEnd: undefined,
query: "file.ts",
cwd: "/Users/test/project",
includeFiles: true,
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);
});
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 () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
@@ -35,6 +35,7 @@ export interface AssistantFileLinkResolverDependencies {
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
@@ -103,7 +104,15 @@ export function createAssistantFileLinkResolver(
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 };
}
@@ -215,6 +224,7 @@ async function resolveAmbiguousCandidate(input: {
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
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;
}
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 {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
@@ -1497,6 +1497,7 @@ test("requests directory suggestions via RPC", async () => {
cwd: "/tmp/project",
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
},
"req-directories",
);
@@ -1508,6 +1509,7 @@ test("requests directory suggestions via RPC", async () => {
expect(request.cwd).toBe("/tmp/project");
expect(request.includeFiles).toBe(true);
expect(request.includeDirectories).toBe(true);
expect(request.matchMode).toBe("suffix");
expect(request.limit).toBe(10);
expect(request.requestId).toBe("req-directories");
@@ -3090,6 +3090,7 @@ export class DaemonClient {
cwd?: string;
includeFiles?: boolean;
includeDirectories?: boolean;
matchMode?: "fuzzy" | "suffix";
},
requestId?: string,
): Promise<DirectorySuggestionsPayload> {
@@ -3101,6 +3102,7 @@ export class DaemonClient {
cwd: options.cwd,
includeFiles: options.includeFiles,
includeDirectories: options.includeDirectories,
matchMode: options.matchMode,
limit: options.limit,
},
responseType: "directory_suggestions_response",
+2 -1
View File
@@ -4705,7 +4705,7 @@ export class Session {
}
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 {
const workspaceCwd = cwd?.trim();
@@ -4716,6 +4716,7 @@ export class Session {
limit,
includeFiles,
includeDirectories,
matchMode,
})
: (
await searchHomeDirectories({
@@ -330,6 +330,7 @@ describe("shared messages stream parsing", () => {
cwd: "/tmp/project",
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
limit: 20,
requestId: "req-dir-1",
});
+1
View File
@@ -1441,6 +1441,7 @@ export const DirectorySuggestionsRequestSchema = z.object({
cwd: z.string().optional(),
includeFiles: z.boolean().optional(),
includeDirectories: z.boolean().optional(),
matchMode: z.enum(["fuzzy", "suffix"]).optional(),
limit: z.number().int().min(1).max(100).optional(),
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.
it.skipIf(isWindows)(
"supports path-style queries and does not escape cwd through symlinks",
@@ -23,10 +23,13 @@ export interface SearchWorkspaceEntriesOptions {
limit?: number;
includeFiles?: boolean;
includeDirectories?: boolean;
matchMode?: WorkspaceMatchMode;
maxDepth?: number;
maxEntriesScanned?: number;
}
export type WorkspaceMatchMode = "fuzzy" | "suffix";
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6;
@@ -153,7 +156,8 @@ export async function searchWorkspaceEntries(
return [];
}
if (queryParts.isPathQuery) {
const matchMode = options.matchMode ?? "fuzzy";
if (queryParts.isPathQuery && matchMode !== "suffix") {
return searchWorkspaceWithinParentDirectory({
workspaceRoot,
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({
workspaceRoot,
searchTerm: queryParts.searchTerm,
searchTerm,
limit,
includeDirectories,
includeFiles,
matchMode,
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
});
@@ -328,6 +337,7 @@ async function searchWorkspaceAcrossTree(input: {
limit: number;
includeDirectories: boolean;
includeFiles: boolean;
matchMode: WorkspaceMatchMode;
maxDepth: number;
maxEntriesScanned: number;
}): Promise<WorkspaceSuggestionEntry[]> {
@@ -375,6 +385,16 @@ async function searchWorkspaceAcrossTree(input: {
if (entry.kind === "file" && !input.includeFiles) {
continue;
}
if (
input.matchMode === "suffix" &&
!workspaceEntryMatchesSuffixQuery({
absolutePath: entry.absolutePath,
workspaceRoot: input.workspaceRoot,
query: input.searchTerm,
})
) {
continue;
}
const rankedEntry = rankWorkspaceEntry({
absolutePath: entry.absolutePath,
@@ -382,7 +402,11 @@ async function searchWorkspaceAcrossTree(input: {
workspaceRoot: input.workspaceRoot,
searchLower,
});
if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) {
if (
input.matchMode !== "suffix" &&
searchLower &&
rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER
) {
continue;
}
@@ -393,6 +417,34 @@ async function searchWorkspaceAcrossTree(input: {
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(
rankedEntries: RankedWorkspaceEntry[],
): WorkspaceSuggestionEntry[] {