Fix inline code file matching
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user