fix(search): address workspace search review feedback

This commit is contained in:
Mohamed Boudra
2026-07-01 19:07:53 +02:00
parent 9905b0da31
commit 7c02b9ec7c
4 changed files with 193 additions and 31 deletions
+12
View File
@@ -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: |
+22 -16
View File
@@ -211,6 +211,25 @@ async function openReadyMockAgent(
}
}
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
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<void> {
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();
}
@@ -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"), "");
@@ -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<FileFinder> {
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<FileFinder> {
}
const { FileFinder } = await loadFffModule();
pruneWorkspaceFinderCache();
const afterImportCached = workspaceFinderCache.get(workspaceRoot);
if (
afterImportCached &&
@@ -140,6 +170,7 @@ async function getWorkspaceFinder(workspaceRoot: string): Promise<FileFinder> {
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<WorkspaceSuggestionEntry[]> {
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<Pick<InitOptions, "enableFsRootScanning" | "enableHomeDirScanning">> {
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<string, unknown>)["."];
if (typeof rootExport === "string") {
return rootExport;
}
if (!rootExport || typeof rootExport !== "object") {
return null;
}
const conditions = rootExport as Record<string, unknown>;
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) {