mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
fix(search): address workspace search review feedback
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user