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");
}