From 7c02b9ec7c2aaa3b88718785fd03916f60a95687 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 1 Jul 2026 19:07:53 +0200 Subject: [PATCH] fix(search): address workspace search review feedback --- .github/workflows/desktop-release.yml | 12 ++ .../app/e2e/composer-autocomplete.spec.ts | 38 +++-- .../search/directory-suggestions.test.ts | 23 ++- .../src/server/search/workspace-entries.ts | 151 ++++++++++++++++-- 4 files changed, 193 insertions(+), 31 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 5d3c8636c7..14ccfd3e58 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -329,6 +329,18 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install Windows arm64 native search packages + shell: bash + run: | + set -euo pipefail + fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")" + ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")" + npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \ + "@ff-labs/fff-bin-win32-arm64@${fff_version}" \ + "@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}" + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set desktop package version from tag shell: bash run: | diff --git a/packages/app/e2e/composer-autocomplete.spec.ts b/packages/app/e2e/composer-autocomplete.spec.ts index 52b9f49498..0b70ab88ab 100644 --- a/packages/app/e2e/composer-autocomplete.spec.ts +++ b/packages/app/e2e/composer-autocomplete.spec.ts @@ -211,6 +211,25 @@ async function openReadyMockAgent( } } +async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise { + await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n"); + await mkdir(path.join(cwd, ".opencode"), { recursive: true }); + await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n"); +} + +async function expectFileMentionSuggestion( + page: Page, + query: string, + suggestion: string, +): Promise { + const input = composerLocator(page); + await input.fill(query); + const popover = page.getByTestId("composer-autocomplete-popover"); + await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); +} + async function visiblePopoverBox( page: Page, ): Promise<{ top: number; bottom: number; height: number }> { @@ -528,10 +547,7 @@ test.describe("Composer autocomplete", () => { }); try { - await writeFile(path.join(session.cwd, ".env.local"), "PASEO_E2E=1\n"); - await mkdir(path.join(session.cwd, ".opencode"), { recursive: true }); - await writeFile(path.join(session.cwd, ".opencode", "settings.json"), "{}\n"); - + await seedDotPrefixedWorkspaceFiles(session.cwd); await openAgentRoute(page, session); await expectWorkspaceTabVisible(page, session.agentId); await expectComposerVisible(page); @@ -539,18 +555,8 @@ test.describe("Composer autocomplete", () => { const input = composerLocator(page); await expect(input).toBeEditable({ timeout: 30_000 }); - await input.fill("@.env"); - const popover = page.getByTestId("composer-autocomplete-popover"); - await expect(popover.getByText(".env.local", { exact: true }).first()).toBeVisible({ - timeout: 30_000, - }); - - await input.fill("@.opencode"); - await expect( - popover.getByText(".opencode/settings.json", { exact: true }).first(), - ).toBeVisible({ - timeout: 30_000, - }); + await expectFileMentionSuggestion(page, "@.env", ".env.local"); + await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json"); } finally { await session.cleanup(); } diff --git a/packages/server/src/server/search/directory-suggestions.test.ts b/packages/server/src/server/search/directory-suggestions.test.ts index c7846af6fa..8cc7be1ca3 100644 --- a/packages/server/src/server/search/directory-suggestions.test.ts +++ b/packages/server/src/server/search/directory-suggestions.test.ts @@ -495,12 +495,33 @@ describe("searchWorkspaceEntries", () => { }); expect(results).toContainEqual({ path: ".claude", kind: "directory" }); - expect(results).toContainEqual({ + expect(results).not.toContainEqual({ path: ".claude/settings.local.json", kind: "file", }); }); + it("constrains fuzzy path-style queries to the typed parent directory", async () => { + mkdirSync(path.join(workspaceDir, "src", "nested"), { recursive: true }); + writeFileSync(path.join(workspaceDir, "src", "config.ts"), ""); + writeFileSync(path.join(workspaceDir, "src", "nested", "config.ts"), ""); + writeFileSync(path.join(workspaceDir, "docs", "config.ts"), ""); + + const results = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "src/co", + limit: 20, + includeFiles: true, + includeDirectories: true, + matchMode: "fuzzy", + }); + + expect(results).toContainEqual({ path: "src/components", kind: "directory" }); + expect(results).toContainEqual({ path: "src/config.ts", kind: "file" }); + expect(results).not.toContainEqual({ path: "src/nested/config.ts", kind: "file" }); + expect(results).not.toContainEqual({ path: "docs/config.ts", kind: "file" }); + }); + it("does not traverse .git while searching workspace files", async () => { mkdirSync(path.join(workspaceDir, ".git", "objects", "ab"), { recursive: true }); writeFileSync(path.join(workspaceDir, ".git", "objects", "ab", "deadbeef"), ""); diff --git a/packages/server/src/server/search/workspace-entries.ts b/packages/server/src/server/search/workspace-entries.ts index 297456c33e..8830c16cda 100644 --- a/packages/server/src/server/search/workspace-entries.ts +++ b/packages/server/src/server/search/workspace-entries.ts @@ -1,8 +1,16 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { realpath, stat } from "node:fs/promises"; +import { homedir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { DirItem, FileFinder, FileItem, MixedItem, Result } from "@ff-labs/fff-node"; +import type { + DirItem, + FileFinder, + FileItem, + InitOptions, + MixedItem, + Result, +} from "@ff-labs/fff-node"; import { isPathInsideRoot } from "../../utils/path.js"; export type WorkspaceSuggestionKind = "file" | "directory"; @@ -30,6 +38,18 @@ interface WorkspaceFinderCacheEntry { expiresAt: number; } +interface WorkspaceQueryParts { + isPathQuery: boolean; + normalizedQuery: string; + parentPart: string; + searchTerm: string; +} + +interface PackagedFffPackageJson { + main?: unknown; + exports?: unknown; +} + const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; const FFF_SCAN_WAIT_TIMEOUT_MS = 5_000; @@ -75,14 +95,19 @@ export async function searchWorkspaceEntries( } const searchQuery = - matchMode === "suffix" - ? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/") + matchMode === "suffix" || queryParts.isPathQuery + ? queryParts.normalizedQuery : queryParts.searchTerm; + const parentConstraint = + matchMode === "fuzzy" && queryParts.isPathQuery ? queryParts.parentPart : null; const finder = await getWorkspaceFinder(workspaceRoot); const scan = await finder.waitForScan(FFF_SCAN_WAIT_TIMEOUT_MS); if (!scan.ok) { throw new Error(`Workspace search scan failed: ${scan.error}`); } + if (!scan.value) { + throw new Error(`Workspace search scan timed out after ${FFF_SCAN_WAIT_TIMEOUT_MS}ms`); + } const candidates = await searchFffEntries({ finder, @@ -96,6 +121,7 @@ export async function searchWorkspaceEntries( includeDirectories, includeFiles, matchMode, + parentConstraint, suffixQuery: searchQuery, }); const deduped = dedupeWorkspaceEntries(entries); @@ -113,6 +139,8 @@ export function clearWorkspaceSearchCacheForTests(): void { } async function getWorkspaceFinder(workspaceRoot: string): Promise { + pruneWorkspaceFinderCache(); + const now = Date.now(); const cached = workspaceFinderCache.get(workspaceRoot); if (cached && cached.expiresAt > now && !cached.finder.isDestroyed) { @@ -125,6 +153,8 @@ async function getWorkspaceFinder(workspaceRoot: string): Promise { } const { FileFinder } = await loadFffModule(); + pruneWorkspaceFinderCache(); + const afterImportCached = workspaceFinderCache.get(workspaceRoot); if ( afterImportCached && @@ -140,6 +170,7 @@ async function getWorkspaceFinder(workspaceRoot: string): Promise { aiMode: true, disableMmapCache: true, disableContentIndexing: true, + ...(await getRootScanningOptions(workspaceRoot)), }); if (!created.ok) { throw new Error(`Workspace search initialization failed: ${created.error}`); @@ -169,17 +200,16 @@ function resolvePackagedFffEntrypoint(): string | null { return null; } - const candidate = path.join( + const packageRoot = path.join( resourcesPath, "app.asar.unpacked", "node_modules", "@ff-labs", "fff-node", - "dist", - "src", - "index.js", ); - return existsSync(candidate) ? candidate : null; + // Load the unpacked package entry declared by FFF instead of duplicating its dist layout here. + const candidate = resolvePackagedFffEntrypointFromPackage(packageRoot); + return candidate && existsSync(candidate) ? candidate : null; } async function searchFffEntries(input: { @@ -208,6 +238,8 @@ async function searchFffEntries(input: { input.finder.directorySearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }), "Workspace directory search failed", ); + // FFF directory search only returns directories whose own names match. File search lets us + // derive matching ancestor directories from files nested below them. const fileResult = unwrapFffResult( input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }), "Workspace file search failed", @@ -249,6 +281,7 @@ async function normalizeFffEntries(input: { includeDirectories: boolean; includeFiles: boolean; matchMode: WorkspaceMatchMode; + parentConstraint: string | null; suffixQuery: string; }): Promise { const entries: WorkspaceSuggestionEntry[] = []; @@ -265,6 +298,15 @@ async function normalizeFffEntries(input: { if (entry.kind === "file" && !input.includeFiles) { continue; } + if ( + input.parentConstraint !== null && + !workspaceEntryIsDirectChildOfParent({ + relativePath: entry.path, + parentPart: input.parentConstraint, + }) + ) { + continue; + } if ( input.matchMode === "suffix" && !workspaceEntryMatchesSuffixQuery({ @@ -485,8 +527,9 @@ function workspaceEntryMatchesSuffixQuery(input: { relativePath: string; query: function normalizeWorkspaceQueryParts( query: string, workspaceRoot: string, -): { parentPart: string; searchTerm: string } | null { +): WorkspaceQueryParts | null { let normalized = normalizeFffRelativePath(query.trim()); + let isPathQuery = normalized.startsWith("./") || normalized.startsWith("../"); if (path.isAbsolute(normalized)) { const absolute = path.resolve(normalized); @@ -494,11 +537,15 @@ function normalizeWorkspaceQueryParts( return null; } normalized = normalizeRelativePath(workspaceRoot, absolute); + isPathQuery = true; } normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); + isPathQuery = isPathQuery || normalized.includes("/"); if (!normalized) { return { + isPathQuery, + normalizedQuery: "", parentPart: "", searchTerm: "", }; @@ -506,11 +553,91 @@ function normalizeWorkspaceQueryParts( const slashIndex = normalized.lastIndexOf("/"); return { + isPathQuery, + normalizedQuery: normalized, parentPart: slashIndex >= 0 ? normalized.slice(0, slashIndex) : "", searchTerm: slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized, }; } +async function getRootScanningOptions( + workspaceRoot: string, +): Promise> { + const homeRoot = await resolveDirectory(homedir()); + return { + enableFsRootScanning: workspaceRoot === path.parse(workspaceRoot).root, + enableHomeDirScanning: homeRoot === workspaceRoot, + }; +} + +function resolvePackagedFffEntrypointFromPackage(packageRoot: string): string | null { + const packageJsonPath = path.join(packageRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + return null; + } + + let packageJson: PackagedFffPackageJson; + try { + packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackagedFffPackageJson; + } catch { + return null; + } + + const entrypoint = getPackageEntrypoint(packageJson) ?? null; + return entrypoint ? path.resolve(packageRoot, entrypoint) : null; +} + +function getPackageEntrypoint(packageJson: PackagedFffPackageJson): string | null { + const exportedEntrypoint = getRootExportEntrypoint(packageJson.exports); + if (exportedEntrypoint) { + return exportedEntrypoint; + } + return typeof packageJson.main === "string" ? packageJson.main : null; +} + +function getRootExportEntrypoint(exportsField: unknown): string | null { + if (typeof exportsField === "string") { + return exportsField; + } + if (!exportsField || typeof exportsField !== "object") { + return null; + } + + const rootExport = (exportsField as Record)["."]; + if (typeof rootExport === "string") { + return rootExport; + } + if (!rootExport || typeof rootExport !== "object") { + return null; + } + + const conditions = rootExport as Record; + if (typeof conditions.import === "string") { + return conditions.import; + } + return typeof conditions.default === "string" ? conditions.default : null; +} + +function workspaceEntryIsDirectChildOfParent(input: { + relativePath: string; + parentPart: string; +}): boolean { + const relativePath = normalizeFffRelativePath(input.relativePath).replace(/^\.\/+/, ""); + const parentPart = normalizeFffRelativePath(input.parentPart) + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + + if (!parentPart) { + return relativePath.length > 0 && !relativePath.includes("/"); + } + if (!relativePath.startsWith(`${parentPart}/`)) { + return false; + } + + const childPart = relativePath.slice(parentPart.length + 1); + return childPart.length > 0 && !childPart.includes("/"); +} + function normalizeLimit(limit: number | undefined): number { const candidate = limit ?? DEFAULT_LIMIT; if (!Number.isFinite(candidate)) { @@ -546,10 +673,6 @@ function normalizeFffRelativePath(value: string): string { } function pruneWorkspaceFinderCache(): void { - if (workspaceFinderCache.size <= FFF_FINDER_CACHE_MAX_ENTRIES) { - return; - } - const now = Date.now(); for (const [cacheKey, entry] of workspaceFinderCache) { if (entry.expiresAt <= now || entry.finder.isDestroyed) {