diff --git a/packages/app/src/assistant-file-links/parse.test.ts b/packages/app/src/assistant-file-links/parse.test.ts index aa7663db0..9410f3da4 100644 --- a/packages/app/src/assistant-file-links/parse.test.ts +++ b/packages/app/src/assistant-file-links/parse.test.ts @@ -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", () => { diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 0f1d0351c..c46a67de1 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -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); } diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 82a4efc80..d085611e0 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -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() { 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) {} + + createResolver() { + return createAssistantFileLinkResolver({ + getDirectorySuggestions: this.getDirectorySuggestions, + openWorkspaceFile: this.openWorkspaceFile, + openExternalUrl: async () => {}, + onUnresolvedFileCandidate: this.onUnresolvedFileCandidate, + }); + } + + private getDirectorySuggestions = async ( + search: DirectorySearch, + ): Promise => { + 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({ diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index e9c32cd84..f1310c642 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -35,6 +35,7 @@ export interface AssistantFileLinkResolverDependencies { cwd: string; includeFiles: true; includeDirectories: false; + matchMode: "suffix"; limit: number; }) => Promise; 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"); } diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 0b120649d..f79d7014f 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -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"); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 669c19e98..3baae77fb 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -3090,6 +3090,7 @@ export class DaemonClient { cwd?: string; includeFiles?: boolean; includeDirectories?: boolean; + matchMode?: "fuzzy" | "suffix"; }, requestId?: string, ): Promise { @@ -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", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e7c7cf65a..2e6a383d6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4705,7 +4705,7 @@ export class Session { } private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise { - 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({ diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index e5f1d97f9..d4ea77d20 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -330,6 +330,7 @@ describe("shared messages stream parsing", () => { cwd: "/tmp/project", includeFiles: true, includeDirectories: true, + matchMode: "suffix", limit: 20, requestId: "req-dir-1", }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index c2799619e..fc4eeafaf 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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(), }); diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 2e62fe0a8..1fec0afc9 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -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", diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 32f016679..383f6d770 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -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 { @@ -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[] {