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