From 9905b0da3191802096e18e34e8d279ab4f5cc23c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 30 Jun 2026 23:46:32 +0200 Subject: [PATCH] fix(search): include dot-prefixed workspace entries Workspace suggestions now use the indexed search backend directly, so dotfiles and dot directories are included without a second ignore layer. Packaged desktop smoke covers the native search path. --- docs/development.md | 8 + package-lock.json | 332 +++++ .../app/e2e/composer-autocomplete.spec.ts | 37 + packages/desktop/electron-builder.yml | 4 + packages/desktop/scripts/after-pack.js | 70 + .../scripts/smoke-packaged-desktop-app.js | 97 +- .../src/daemon/desktop-packaging.test.ts | 9 + packages/server/package.json | 1 + .../search}/directory-suggestions.test.ts | 93 +- .../src/server/search/home-directories.ts | 478 +++++++ .../src/server/search/workspace-entries.ts | 583 +++++++++ packages/server/src/server/session.ts | 3 +- .../server/src/utils/directory-suggestions.ts | 1126 ----------------- scripts/trace-daemon.mjs | 32 + 14 files changed, 1713 insertions(+), 1160 deletions(-) rename packages/server/src/{utils => server/search}/directory-suggestions.test.ts (85%) create mode 100644 packages/server/src/server/search/home-directories.ts create mode 100644 packages/server/src/server/search/workspace-entries.ts delete mode 100644 packages/server/src/utils/directory-suggestions.ts diff --git a/docs/development.md b/docs/development.md index 55624c9e1..69d197258 100644 --- a/docs/development.md +++ b/docs/development.md @@ -169,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in `PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the defaults. The default rotation is `10m` x `3` files everywhere. +### Workspace search + +Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in +`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as +the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore` +matcher in Paseo. If ignore semantics need to change, change the backend +contract and keep the packaged desktop smoke covering dot-prefixed suggestions. + ## paseo.json service scripts `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array diff --git a/package-lock.json b/package-lock.json index 1c2e712ad..c099f53dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6154,6 +6154,141 @@ "excpretty": "build/cli.js" } }, + "node_modules/@ff-labs/fff-bin-darwin-arm64": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz", + "integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ff-labs/fff-bin-darwin-x64": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz", + "integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ff-labs/fff-bin-linux-arm64-gnu": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz", + "integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ff-labs/fff-bin-linux-arm64-musl": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz", + "integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ff-labs/fff-bin-linux-x64-gnu": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz", + "integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ff-labs/fff-bin-linux-x64-musl": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz", + "integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ff-labs/fff-bin-win32-arm64": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz", + "integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ff-labs/fff-bin-win32-x64": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz", + "integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ff-labs/fff-node": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz", + "integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==", + "cpu": [ + "x64", + "arm64" + ], + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "ffi-rs": "^1.0.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@ff-labs/fff-bin-darwin-arm64": "0.9.6", + "@ff-labs/fff-bin-darwin-x64": "0.9.6", + "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6", + "@ff-labs/fff-bin-linux-arm64-musl": "0.9.6", + "@ff-labs/fff-bin-linux-x64-gnu": "0.9.6", + "@ff-labs/fff-bin-linux-x64-musl": "0.9.6", + "@ff-labs/fff-bin-win32-arm64": "0.9.6", + "@ff-labs/fff-bin-win32-x64": "0.9.6" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -13761,6 +13896,183 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/@yuuang/ffi-rs-android-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz", + "integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz", + "integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz", + "integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-arm64-gnu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz", + "integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-arm64-musl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz", + "integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-x64-gnu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz", + "integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-x64-musl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz", + "integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-win32-arm64-msvc": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz", + "integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-win32-ia32-msvc": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz", + "integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==", + "cpu": [ + "x64", + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-win32-x64-msvc": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz", + "integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -21209,6 +21521,25 @@ "integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==", "license": "MIT" }, + "node_modules/ffi-rs": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz", + "integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==", + "license": "MIT", + "optionalDependencies": { + "@yuuang/ffi-rs-android-arm64": "1.3.2", + "@yuuang/ffi-rs-darwin-arm64": "1.3.2", + "@yuuang/ffi-rs-darwin-x64": "1.3.2", + "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", + "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", + "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", + "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", + "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", + "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", + "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", + "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -38021,6 +38352,7 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", + "@ff-labs/fff-node": "^0.9.6", "@getpaseo/client": "0.1.102", "@getpaseo/highlight": "0.1.102", "@getpaseo/protocol": "0.1.102", diff --git a/packages/app/e2e/composer-autocomplete.spec.ts b/packages/app/e2e/composer-autocomplete.spec.ts index b813c605a..52b9f4949 100644 --- a/packages/app/e2e/composer-autocomplete.spec.ts +++ b/packages/app/e2e/composer-autocomplete.spec.ts @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; import { expect, test, type Page } from "./fixtures"; import { composerLocator, expectComposerVisible } from "./helpers/composer"; import { @@ -519,6 +521,41 @@ test.describe("Composer autocomplete", () => { } }); + test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "autocomplete-dot-entries-", + title: "Dot file mention 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 openAgentRoute(page, session); + await expectWorkspaceTabVisible(page, session.agentId); + await expectComposerVisible(page); + + 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, + }); + } finally { + await session.cleanup(); + } + }); + test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => { await installListCommandsStub(page); const agent = await openReadyMockAgent(page); diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index c0534e146..827f706e4 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -16,6 +16,10 @@ files: asarUnpack: - dist/daemon/node-entrypoint-runner.js - node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/* + - node_modules/@ff-labs/fff-node/**/* + - node_modules/@ff-labs/fff-bin-*/**/* + - node_modules/ffi-rs/**/* + - node_modules/@yuuang/ffi-rs-*/**/* extraResources: - from: ../app/dist to: app-dist diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index ecbc69fd9..e88a45b84 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -14,6 +14,38 @@ const RIPGREP_PLATFORM_DIR = { win32: { arm64: "arm64-win32", x64: "x64-win32" }, }; +const FFF_BIN_PACKAGE = { + darwin: { + arm64: ["fff-bin-darwin-arm64"], + x64: ["fff-bin-darwin-x64"], + universal: ["fff-bin-darwin-arm64", "fff-bin-darwin-x64"], + }, + linux: { + arm64: ["fff-bin-linux-arm64-gnu"], + x64: ["fff-bin-linux-x64-gnu"], + }, + win32: { + arm64: ["fff-bin-win32-arm64"], + x64: ["fff-bin-win32-x64"], + }, +}; + +const FFI_RS_PACKAGE = { + darwin: { + arm64: ["ffi-rs-darwin-arm64"], + x64: ["ffi-rs-darwin-x64"], + universal: ["ffi-rs-darwin-arm64", "ffi-rs-darwin-x64"], + }, + linux: { + arm64: ["ffi-rs-linux-arm64-gnu"], + x64: ["ffi-rs-linux-x64-gnu"], + }, + win32: { + arm64: ["ffi-rs-win32-arm64-msvc"], + x64: ["ffi-rs-win32-x64-msvc"], + }, +}; + function rmSafe(target) { fs.rmSync(target, { recursive: true, force: true }); } @@ -73,6 +105,43 @@ function pruneSharpLibvips(nodeModules, platform, arch) { } } +function keepPackagesForPlatform(packagesByPlatform, platform, arch) { + return packagesByPlatform[platform]?.[arch] ?? []; +} + +function pruneScopedNativePackages(nodeModules, scope, prefix, keepNames) { + if (keepNames.length === 0) { + return; + } + + const scopeDir = path.join(nodeModules, scope); + if (!fs.existsSync(scopeDir)) { + return; + } + + const keep = new Set(keepNames); + for (const entry of fs.readdirSync(scopeDir)) { + if (entry.startsWith(prefix) && !keep.has(entry)) { + rmSafe(path.join(scopeDir, entry)); + } + } +} + +function pruneFffNativePackages(nodeModules, platform, arch) { + pruneScopedNativePackages( + nodeModules, + "@ff-labs", + "fff-bin-", + keepPackagesForPlatform(FFF_BIN_PACKAGE, platform, arch), + ); + pruneScopedNativePackages( + nodeModules, + "@yuuang", + "ffi-rs-", + keepPackagesForPlatform(FFI_RS_PACKAGE, platform, arch), + ); +} + function pruneNativeModules(appOutDir, platform, arch) { const resourcesDir = platform === "darwin" @@ -87,6 +156,7 @@ function pruneNativeModules(appOutDir, platform, arch) { pruneClaudeAgentSdk(nodeModules, platform, arch); pruneNodePty(nodeModules, platform, arch); pruneSharpLibvips(nodeModules, platform, arch); + pruneFffNativePackages(nodeModules, platform, arch); const after = dirSizeSync(nodeModules); const savedMB = ((before - after) / 1024 / 1024).toFixed(1); diff --git a/packages/desktop/scripts/smoke-packaged-desktop-app.js b/packages/desktop/scripts/smoke-packaged-desktop-app.js index c7c983871..8951b818d 100644 --- a/packages/desktop/scripts/smoke-packaged-desktop-app.js +++ b/packages/desktop/scripts/smoke-packaged-desktop-app.js @@ -1,9 +1,10 @@ -const { spawn, spawnSync } = require("node:child_process"); +const { execFileSync, spawn, spawnSync } = require("node:child_process"); const fs = require("node:fs"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); +const { pathToFileURL } = require("node:url"); const EXECUTABLE_NAME = "Paseo"; const SMOKE_TIMEOUT_MS = 60_000; @@ -621,6 +622,97 @@ async function smokeCliTerminal({ appPath, env }) { } } +async function loadConnectToDaemon() { + const clientModulePath = path.join(__dirname, "..", "..", "cli", "dist", "utils", "client.js"); + const clientModule = await import(pathToFileURL(clientModulePath).href); + if (typeof clientModule.connectToDaemon !== "function") { + throw new Error(`CLI client module did not export connectToDaemon: ${clientModulePath}`); + } + return clientModule.connectToDaemon; +} + +function createWorkspaceSearchFixture() { + const cwd = createTempDir("paseo-smoke-search-cwd-"); + execFileSync("git", ["init"], { cwd, stdio: "ignore" }); + fs.writeFileSync(path.join(cwd, ".env.local"), "PASEO_PACKAGED_SMOKE=1\n"); + fs.mkdirSync(path.join(cwd, ".opencode"), { recursive: true }); + fs.writeFileSync(path.join(cwd, ".opencode", "settings.json"), "{}\n"); + fs.writeFileSync(path.join(cwd, ".gitignore"), "ignored.secret\n"); + fs.writeFileSync(path.join(cwd, "ignored.secret"), "ignore me\n"); + return cwd; +} + +function assertSuggestionPayload(payload, label) { + if (payload?.error) { + throw new Error(`${label} returned error: ${payload.error}`); + } + if (!Array.isArray(payload?.entries)) { + throw new Error(`${label} returned invalid payload: ${JSON.stringify(payload)}`); + } +} + +function assertSuggestionEntry(payload, expected, label) { + assertSuggestionPayload(payload, label); + const found = payload.entries.some( + (entry) => entry.path === expected.path && entry.kind === expected.kind, + ); + if (!found) { + throw new Error( + `${label} did not include ${expected.kind} ${expected.path}. Entries: ${JSON.stringify( + payload.entries, + )}`, + ); + } +} + +async function smokeWorkspaceSearch({ host }) { + console.log("Packaged desktop smoke: querying workspace search through daemon RPC"); + const cwd = createWorkspaceSearchFixture(); + const connectToDaemon = await loadConnectToDaemon(); + const client = await connectToDaemon({ host, timeout: SMOKE_TIMEOUT_MS }); + + try { + const envResults = await client.getDirectorySuggestions({ + cwd, + query: "env", + includeFiles: true, + includeDirectories: true, + limit: 20, + }); + assertSuggestionEntry(envResults, { path: ".env.local", kind: "file" }, "Dotfile search"); + + const opencodeResults = await client.getDirectorySuggestions({ + cwd, + query: "opencode", + includeFiles: true, + includeDirectories: true, + limit: 20, + }); + assertSuggestionEntry( + opencodeResults, + { path: ".opencode/settings.json", kind: "file" }, + "Dot-directory search", + ); + + const ignoredResults = await client.getDirectorySuggestions({ + cwd, + query: "ignored", + includeFiles: true, + includeDirectories: true, + limit: 20, + }); + assertSuggestionPayload(ignoredResults, "Ignored file search"); + if (ignoredResults.entries.some((entry) => entry.path === "ignored.secret")) { + throw new Error( + `Ignored file search returned ignored.secret: ${JSON.stringify(ignoredResults.entries)}`, + ); + } + } finally { + await client.close().catch(() => {}); + await removeTempDir(cwd); + } +} + async function stopCliDaemon({ appPath, env }) { console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim"); await runCliShimCommand({ @@ -677,9 +769,10 @@ async function smokePackagedDesktopApp({ appPath }) { const cliEnv = createDefaultDaemonEnv(); await smokeCliShim({ appPath, env: cliEnv }); await smokeCliTerminal({ appPath, env: cliEnv }); + await smokeWorkspaceSearch({ host: message.status.listen }); await stopDaemonForCleanup(); console.log( - `Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status and terminal smoke succeeded`, + `Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status, terminal smoke, and workspace search succeeded`, ); } catch (error) { if (smokeStarted && !daemonStopped) { diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 8676f0087..0bed9b2ed 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -74,6 +74,15 @@ describe("desktop packaging", () => { ); }); + it("unpacks native workspace search packages", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain("node_modules/@ff-labs/fff-node/**/*"); + expect(config).toContain("node_modules/@ff-labs/fff-bin-*/**/*"); + expect(config).toContain("node_modules/ffi-rs/**/*"); + expect(config).toContain("node_modules/@yuuang/ffi-rs-*/**/*"); + }); + it("excludes package debug/source files from the packaged app", () => { const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); diff --git a/packages/server/package.json b/packages/server/package.json index c879ec8ef..40bfccc65 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -65,6 +65,7 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.195", "@anthropic-ai/sdk": "^0.104.2", + "@ff-labs/fff-node": "^0.9.6", "@getpaseo/client": "0.1.102", "@getpaseo/highlight": "0.1.102", "@getpaseo/protocol": "0.1.102", diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/server/search/directory-suggestions.test.ts similarity index 85% rename from packages/server/src/utils/directory-suggestions.test.ts rename to packages/server/src/server/search/directory-suggestions.test.ts index bb6e1d3f7..c7846af6f 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/server/search/directory-suggestions.test.ts @@ -1,9 +1,11 @@ +import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { isPlatform } from "../test-utils/platform.js"; -import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js"; +import { isPlatform } from "../../test-utils/platform.js"; +import { searchHomeDirectories } from "./home-directories.js"; +import { clearWorkspaceSearchCacheForTests, searchWorkspaceEntries } from "./workspace-entries.js"; const isWindows = isPlatform("win32"); @@ -204,6 +206,7 @@ describe("searchWorkspaceEntries", () => { }); mkdirSync(path.join(workspaceDir, "docs"), { recursive: true }); mkdirSync(path.join(outsideDir, "escaped"), { recursive: true }); + execFileSync("git", ["init"], { cwd: workspaceDir, stdio: "ignore" }); writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n"); writeFileSync( @@ -218,6 +221,7 @@ describe("searchWorkspaceEntries", () => { }); afterEach(() => { + clearWorkspaceSearchCacheForTests(); rmSync(tempRoot, { recursive: true, force: true }); }); @@ -322,17 +326,17 @@ describe("searchWorkspaceEntries", () => { 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" }, - ]); + const expectedMatches = [ + { path: "src/file.ts", kind: "file" as const }, + { path: "packages/app/src/file.ts", kind: "file" as const }, + ]; + expect(basenameResults).toHaveLength(2); + expect(basenameResults).toEqual(expect.arrayContaining(expectedMatches)); + expect(suffixResults).toHaveLength(2); + expect(suffixResults).toEqual(expect.arrayContaining(expectedMatches)); }); - it("suffix mode resolves exact workspace file paths before broad traversal", async () => { + it("suffix mode resolves exact workspace file paths", async () => { const targetPath = path.join( workspaceDir, "packages", @@ -353,7 +357,6 @@ describe("searchWorkspaceEntries", () => { includeFiles: true, includeDirectories: false, matchMode: "suffix", - maxEntriesScanned: 1, }); expect(results).toEqual([ @@ -364,7 +367,7 @@ describe("searchWorkspaceEntries", () => { ]); }); - it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => { + it("suffix mode resolves explicit dot-prefixed file paths", async () => { const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log"); mkdirSync(path.dirname(targetPath), { recursive: true }); writeFileSync(targetPath, "daemon log\n"); @@ -376,13 +379,40 @@ describe("searchWorkspaceEntries", () => { includeFiles: true, includeDirectories: false, matchMode: "suffix", - maxEntriesScanned: 1, }); expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]); }); - it("suffix mode finds files under allowlisted hidden workspace directories", async () => { + it("finds dot-prefixed files and files under dot-prefixed directories", async () => { + mkdirSync(path.join(workspaceDir, ".opencode"), { recursive: true }); + writeFileSync(path.join(workspaceDir, ".env.local"), "PASEO_TEST=1\n"); + writeFileSync(path.join(workspaceDir, ".opencode", "settings.json"), "{}"); + + const envResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "env", + limit: 20, + includeFiles: true, + includeDirectories: true, + }); + const opencodeResults = await searchWorkspaceEntries({ + cwd: workspaceDir, + query: "opencode", + limit: 20, + includeFiles: true, + includeDirectories: true, + }); + + expect(envResults).toContainEqual({ path: ".env.local", kind: "file" }); + expect(opencodeResults).toContainEqual({ path: ".opencode", kind: "directory" }); + expect(opencodeResults).toContainEqual({ + path: ".opencode/settings.json", + kind: "file", + }); + }); + + it("suffix mode finds files under dot-prefixed workspace directories", async () => { mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true }); writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); @@ -409,7 +439,7 @@ describe("searchWorkspaceEntries", () => { expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]); }); - it("does not broadly traverse unlisted hidden workspace directories", async () => { + it("searches files under arbitrary dot-prefixed workspace directories", async () => { mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true }); writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), ""); writeFileSync(path.join(workspaceDir, "src", "needle.ts"), ""); @@ -423,10 +453,15 @@ describe("searchWorkspaceEntries", () => { matchMode: "suffix", }); - expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]); + expect(results).toEqual( + expect.arrayContaining([ + { path: ".dev/cache/needle.ts", kind: "file" }, + { path: "src/needle.ts", kind: "file" }, + ]), + ); }); - it("does not suggest hidden directories even when includeDirectories is true", async () => { + it("suggests dot-prefixed directories when includeDirectories is true", async () => { mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); @@ -439,35 +474,31 @@ describe("searchWorkspaceEntries", () => { matchMode: "fuzzy", }); - expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe( - false, - ); + expect(results).toContainEqual({ path: ".claude", kind: "directory" }); expect(results).toContainEqual({ path: ".claude/settings.local.json", kind: "file", }); }); - it("path mode does not suggest hidden workspace directories", async () => { + it("path-style queries include dot-prefixed workspace directories", async () => { mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true }); writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}"); const results = await searchWorkspaceEntries({ cwd: workspaceDir, - query: "./", + query: "./.claude", limit: 20, includeFiles: true, includeDirectories: true, matchMode: "fuzzy", }); + expect(results).toContainEqual({ path: ".claude", kind: "directory" }); expect(results).toContainEqual({ - path: "README.md", + path: ".claude/settings.local.json", kind: "file", }); - expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe( - false, - ); }); it("does not traverse .git while searching workspace files", async () => { @@ -513,7 +544,8 @@ describe("searchWorkspaceEntries", () => { }, ); - it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => { + it("respects workspace ignore rules for node_modules", async () => { + writeFileSync(path.join(workspaceDir, ".gitignore"), "node_modules/\n"); mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true }); writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), ""); @@ -532,7 +564,6 @@ describe("searchWorkspaceEntries", () => { limit: 20, includeFiles: true, includeDirectories: true, - maxEntriesScanned: 60, }); expect(results).toContainEqual({ @@ -542,11 +573,12 @@ describe("searchWorkspaceEntries", () => { expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false); }); - it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => { + it("respects workspace ignore rules for generated trees", async () => { mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true }); writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), ""); const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"]; + writeFileSync(path.join(workspaceDir, ".gitignore"), `${heavyDirs.join("/\n")}/\n`); for (const heavyDir of heavyDirs) { for (let index = 0; index < 30; index += 1) { mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true }); @@ -560,7 +592,6 @@ describe("searchWorkspaceEntries", () => { limit: 20, includeFiles: true, includeDirectories: true, - maxEntriesScanned: 80, }); expect(results).toContainEqual({ diff --git a/packages/server/src/server/search/home-directories.ts b/packages/server/src/server/search/home-directories.ts new file mode 100644 index 000000000..70badd51f --- /dev/null +++ b/packages/server/src/server/search/home-directories.ts @@ -0,0 +1,478 @@ +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { isPathInsideRoot } from "../../utils/path.js"; + +export interface SearchHomeDirectoriesOptions { + homeDir: string; + query: string; + limit?: number; + maxDepth?: number; + maxDirectoriesScanned?: number; +} + +const DEFAULT_LIMIT = 30; +const MAX_LIMIT = 100; +const DEFAULT_MAX_DEPTH = 12; +const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000; +const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; +const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; +const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; +const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; +const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ + "node_modules", + "venv", + "env", + "virtualenv", + "dist", + "build", + "target", + "out", + "coverage", + "vendor", + "__pycache__", + ".git", +]); + +interface QueryParts { + isPathQuery: boolean; + parentPart: string; + searchTerm: string; +} + +interface RankedDirectory { + absolutePath: string; + matchTier: number; + segmentIndex: number; + matchOffset: number; + depth: number; +} + +interface ChildDirectoryEntry { + name: string; + absolutePath: string; +} + +interface DirectoryListCacheEntry { + expiresAt: number; + entries: ChildDirectoryEntry[]; +} + +const directoryListCache = new Map(); + +export async function searchHomeDirectories( + options: SearchHomeDirectoriesOptions, +): Promise { + const query = options.query.trim(); + if (!query) { + return []; + } + + const limit = normalizeLimit(options.limit); + const homeRoot = await resolveDirectory(options.homeDir); + if (!homeRoot) { + return []; + } + + const queryParts = normalizeQueryParts(query, homeRoot); + if (!queryParts) { + return []; + } + + if (queryParts.isPathQuery) { + return searchWithinParentDirectory({ + homeRoot, + parentPart: queryParts.parentPart, + searchTerm: queryParts.searchTerm, + limit, + }); + } + + return searchAcrossHomeTree({ + homeRoot, + searchTerm: queryParts.searchTerm, + limit, + maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, + maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, + }); +} + +function normalizeLimit(limit: number | undefined): number { + const candidate = limit ?? DEFAULT_LIMIT; + if (!Number.isFinite(candidate)) { + return DEFAULT_LIMIT; + } + const bounded = Math.trunc(candidate); + return Math.max(1, Math.min(MAX_LIMIT, bounded)); +} + +async function searchWithinParentDirectory(input: { + homeRoot: string; + parentPart: string; + searchTerm: string; + limit: number; +}): Promise { + const parentPath = path.resolve(input.homeRoot, input.parentPart || "."); + const parentRoot = await resolveDirectory(parentPath); + if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) { + return []; + } + + const searchLower = input.searchTerm.toLowerCase(); + const ranked: RankedDirectory[] = []; + const entries = await listChildDirectories({ + directory: parentRoot, + homeRoot: input.homeRoot, + }); + + for (const entry of entries) { + if (searchLower && !entry.name.toLowerCase().includes(searchLower)) { + continue; + } + + ranked.push( + rankDirectory({ + absolutePath: entry.absolutePath, + homeRoot: input.homeRoot, + searchLower, + }), + ); + } + + return dedupeAndSort(ranked).slice(0, input.limit); +} + +async function searchAcrossHomeTree(input: { + homeRoot: string; + searchTerm: string; + limit: number; + maxDepth: number; + maxDirectoriesScanned: number; +}): Promise { + const queue: Array<{ directory: string; depth: number }> = [ + { directory: input.homeRoot, depth: 0 }, + ]; + const visited = new Set([input.homeRoot]); + const ranked: RankedDirectory[] = []; + let scanned = 0; + const searchLower = input.searchTerm.toLowerCase(); + + for ( + let queueIndex = 0; + queueIndex < queue.length && scanned < input.maxDirectoriesScanned; + queueIndex += 1 + ) { + const current = queue[queueIndex]; + if (!current) continue; + const entries = await listChildDirectories({ + directory: current.directory, + homeRoot: input.homeRoot, + }); + + for (const entry of entries) { + const resolvedCandidate = entry.absolutePath; + if (visited.has(resolvedCandidate)) { + continue; + } + visited.add(resolvedCandidate); + scanned += 1; + + const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate); + if ( + relativePath.toLowerCase().includes(searchLower) || + entry.name.toLowerCase().includes(searchLower) + ) { + ranked.push( + rankDirectory({ + absolutePath: resolvedCandidate, + homeRoot: input.homeRoot, + searchLower, + }), + ); + } + + if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) { + queue.push({ directory: resolvedCandidate, depth: current.depth + 1 }); + } + } + } + + return dedupeAndSort(ranked).slice(0, input.limit); +} + +function dedupeAndSort(ranked: RankedDirectory[]): string[] { + const byPath = new Map(); + for (const entry of ranked) { + const existing = byPath.get(entry.absolutePath); + if (!existing || compareRankedDirectories(entry, existing) < 0) { + byPath.set(entry.absolutePath, entry); + } + } + + return Array.from(byPath.values()) + .sort(compareRankedDirectories) + .map((entry) => entry.absolutePath); +} + +function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number { + if (left.matchTier !== right.matchTier) { + return left.matchTier - right.matchTier; + } + if (left.segmentIndex !== right.segmentIndex) { + return left.segmentIndex - right.segmentIndex; + } + if (left.matchOffset !== right.matchOffset) { + return left.matchOffset - right.matchOffset; + } + if (left.depth !== right.depth) { + return left.depth - right.depth; + } + return left.absolutePath.localeCompare(right.absolutePath); +} + +function rankDirectory(input: { + absolutePath: string; + homeRoot: string; + searchLower: string; +}): RankedDirectory { + const relative = normalizeRelativePath(input.homeRoot, input.absolutePath); + const relativeLower = relative.toLowerCase(); + const depth = relative === "." ? 0 : relative.split("/").length; + const searchLower = input.searchLower; + if (!searchLower) { + return { + absolutePath: input.absolutePath, + matchTier: 3, + segmentIndex: NO_SEGMENT_INDEX, + matchOffset: 0, + depth, + }; + } + const segments = relativeLower === "." ? [] : relativeLower.split("/"); + const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower); + const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => + segment.startsWith(searchLower), + ); + const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => + segment.includes(searchLower), + ); + const matchOffset = relativeLower.indexOf(searchLower); + let matchTier = 4; + let segmentIndex = NO_SEGMENT_INDEX; + + if (exactSegmentIndex >= 0) { + matchTier = 0; + segmentIndex = exactSegmentIndex; + } else if (prefixSegmentIndex >= 0) { + matchTier = 1; + segmentIndex = prefixSegmentIndex; + } else if (partialSegmentIndex >= 0) { + matchTier = 2; + segmentIndex = partialSegmentIndex; + } else if (relativeLower.startsWith(searchLower)) { + matchTier = 3; + } + + return { + absolutePath: input.absolutePath, + matchTier, + segmentIndex, + matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, + depth, + }; +} + +function findSegmentMatchIndex( + segments: string[], + predicate: (segment: string) => boolean, +): number { + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (!segment) { + continue; + } + if (predicate(segment)) { + return index; + } + } + return -1; +} + +function normalizeRelativePath(homeRoot: string, absolutePath: string): string { + const relative = path.relative(homeRoot, absolutePath); + if (!relative) { + return "."; + } + return relative.split(path.sep).join("/"); +} + +function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { + const typedQuery = query.trim().replace(/\\/g, "/"); + let normalized = typedQuery; + if (!normalized) { + return null; + } + + // Only treat the query as a literal path when the user explicitly roots it + // with ~, ~/, ./, or an absolute path. Bare queries like "faro/main" are + // search terms, not paths. + let isRooted = false; + + if (normalized.startsWith("~")) { + isRooted = true; + normalized = normalized.slice(1); + if (normalized.startsWith("/")) { + normalized = normalized.slice(1); + } + } + + if (path.isAbsolute(normalized)) { + isRooted = true; + const absolute = path.resolve(normalized); + if (!isPathInsideRoot(homeRoot, absolute)) { + return null; + } + normalized = normalizeRelativePath(homeRoot, absolute); + } + + if (normalized.startsWith("./")) { + isRooted = true; + } + normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); + if (!normalized) { + // Treat "~" and "~/" as a request to browse the home root. + if (typedQuery === "~" || typedQuery === "~/") { + return { + isPathQuery: true, + parentPart: "", + searchTerm: "", + }; + } + return null; + } + + const isPathQuery = isRooted && normalized.includes("/"); + if (!isPathQuery) { + return { + isPathQuery: false, + parentPart: "", + searchTerm: normalized, + }; + } + + const slashIndex = normalized.lastIndexOf("/"); + const parentPart = normalized.slice(0, slashIndex); + const searchTerm = normalized.slice(slashIndex + 1); + + return { + isPathQuery: true, + parentPart, + searchTerm, + }; +} + +async function resolveDirectory(inputPath: string): Promise { + try { + const resolved = await realpath(path.resolve(inputPath)); + const stats = await stat(resolved); + if (!stats.isDirectory()) { + return null; + } + return resolved; + } catch { + return null; + } +} + +async function listChildDirectories(input: { + directory: string; + homeRoot: string; +}): Promise { + const now = Date.now(); + const cached = directoryListCache.get(input.directory); + if (cached && cached.expiresAt > now) { + return cached.entries; + } + + const dirents = await readdir(input.directory, { withFileTypes: true }).catch( + () => [] as Dirent[], + ); + const candidates = dirents.filter( + (dirent) => + !isHiddenDirectoryName(dirent.name) && + !isIgnoredSuggestionDirectoryName(dirent.name) && + (dirent.isDirectory() || dirent.isSymbolicLink()), + ); + const resolved = await Promise.all( + candidates.map(async (dirent) => { + const candidatePath = path.join(input.directory, dirent.name); + const absolutePath = await resolveDirectoryCandidate({ + candidatePath, + dirent, + homeRoot: input.homeRoot, + }); + return absolutePath ? { name: dirent.name, absolutePath } : null; + }), + ); + const entries: ChildDirectoryEntry[] = resolved.filter( + (entry): entry is ChildDirectoryEntry => entry !== null, + ); + + setDirectoryListCache(input.directory, { + expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, + entries, + }); + + return entries; +} + +async function resolveDirectoryCandidate(input: { + candidatePath: string; + dirent: Dirent; + homeRoot: string; +}): Promise { + if (input.dirent.isDirectory()) { + const resolved = path.resolve(input.candidatePath); + return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null; + } + + const resolved = await resolveDirectory(input.candidatePath); + if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) { + return null; + } + return resolved; +} + +function isHiddenDirectoryName(name: string): boolean { + return name.startsWith("."); +} + +function isIgnoredSuggestionDirectoryName(name: string): boolean { + return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name); +} + +function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void { + directoryListCache.set(cacheKey, entry); + pruneDirectoryListCache(); +} + +function pruneDirectoryListCache(): void { + if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + return; + } + + const now = Date.now(); + for (const [cacheKey, entry] of directoryListCache) { + if (entry.expiresAt <= now) { + directoryListCache.delete(cacheKey); + } + } + + while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { + const oldestKey = directoryListCache.keys().next().value; + if (!oldestKey) { + return; + } + directoryListCache.delete(oldestKey); + } +} diff --git a/packages/server/src/server/search/workspace-entries.ts b/packages/server/src/server/search/workspace-entries.ts new file mode 100644 index 000000000..297456c33 --- /dev/null +++ b/packages/server/src/server/search/workspace-entries.ts @@ -0,0 +1,583 @@ +import { existsSync } from "node:fs"; +import { realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { DirItem, FileFinder, FileItem, MixedItem, Result } from "@ff-labs/fff-node"; +import { isPathInsideRoot } from "../../utils/path.js"; + +export type WorkspaceSuggestionKind = "file" | "directory"; + +export interface WorkspaceSuggestionEntry { + path: string; + kind: WorkspaceSuggestionKind; +} + +export type WorkspaceMatchMode = "fuzzy" | "suffix"; + +export interface SearchWorkspaceEntriesOptions { + cwd: string; + query: string; + limit?: number; + includeFiles?: boolean; + includeDirectories?: boolean; + matchMode?: WorkspaceMatchMode; +} + +type FffModule = typeof import("@ff-labs/fff-node"); + +interface WorkspaceFinderCacheEntry { + finder: FileFinder; + expiresAt: number; +} + +const DEFAULT_LIMIT = 30; +const MAX_LIMIT = 100; +const FFF_SCAN_WAIT_TIMEOUT_MS = 5_000; +const FFF_SEARCH_PAGE_SIZE = 1_000; +const FFF_FINDER_CACHE_TTL_MS = 120_000; +const FFF_FINDER_CACHE_MAX_ENTRIES = 24; + +const workspaceFinderCache = new Map(); +let fffModulePromise: Promise | null = null; + +export async function searchWorkspaceEntries( + options: SearchWorkspaceEntriesOptions, +): Promise { + const limit = normalizeLimit(options.limit); + const includeDirectories = options.includeDirectories ?? true; + const includeFiles = options.includeFiles ?? false; + if (!includeDirectories && !includeFiles) { + return []; + } + + const workspaceRoot = await resolveDirectory(options.cwd); + if (!workspaceRoot) { + return []; + } + + const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot); + if (!queryParts) { + return []; + } + + const matchMode = options.matchMode ?? "fuzzy"; + const exactEntry = + matchMode === "suffix" + ? await resolveWorkspaceExactEntry({ + workspaceRoot, + query: options.query, + includeDirectories, + includeFiles, + }) + : null; + if (exactEntry && limit <= 1) { + return [exactEntry]; + } + + const searchQuery = + matchMode === "suffix" + ? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/") + : queryParts.searchTerm; + 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}`); + } + + const candidates = await searchFffEntries({ + finder, + query: searchQuery, + includeDirectories, + includeFiles, + }); + const entries = await normalizeFffEntries({ + workspaceRoot, + candidates, + includeDirectories, + includeFiles, + matchMode, + suffixQuery: searchQuery, + }); + const deduped = dedupeWorkspaceEntries(entries); + return exactEntry + ? prependWorkspaceEntry(exactEntry, deduped).slice(0, limit) + : deduped.slice(0, limit); +} + +export function clearWorkspaceSearchCacheForTests(): void { + for (const entry of workspaceFinderCache.values()) { + destroyFinder(entry.finder); + } + workspaceFinderCache.clear(); + fffModulePromise = null; +} + +async function getWorkspaceFinder(workspaceRoot: string): Promise { + const now = Date.now(); + const cached = workspaceFinderCache.get(workspaceRoot); + if (cached && cached.expiresAt > now && !cached.finder.isDestroyed) { + cached.expiresAt = now + FFF_FINDER_CACHE_TTL_MS; + return cached.finder; + } + if (cached) { + workspaceFinderCache.delete(workspaceRoot); + destroyFinder(cached.finder); + } + + const { FileFinder } = await loadFffModule(); + const afterImportCached = workspaceFinderCache.get(workspaceRoot); + if ( + afterImportCached && + afterImportCached.expiresAt > Date.now() && + !afterImportCached.finder.isDestroyed + ) { + afterImportCached.expiresAt = Date.now() + FFF_FINDER_CACHE_TTL_MS; + return afterImportCached.finder; + } + + const created = FileFinder.create({ + basePath: workspaceRoot, + aiMode: true, + disableMmapCache: true, + disableContentIndexing: true, + }); + if (!created.ok) { + throw new Error(`Workspace search initialization failed: ${created.error}`); + } + + workspaceFinderCache.set(workspaceRoot, { + finder: created.value, + expiresAt: Date.now() + FFF_FINDER_CACHE_TTL_MS, + }); + pruneWorkspaceFinderCache(); + return created.value; +} + +async function loadFffModule(): Promise { + if (!fffModulePromise) { + const packagedEntrypoint = resolvePackagedFffEntrypoint(); + fffModulePromise = packagedEntrypoint + ? import(pathToFileURL(packagedEntrypoint).href) + : import("@ff-labs/fff-node"); + } + return fffModulePromise; +} + +function resolvePackagedFffEntrypoint(): string | null { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + if (!resourcesPath) { + return null; + } + + const candidate = path.join( + resourcesPath, + "app.asar.unpacked", + "node_modules", + "@ff-labs", + "fff-node", + "dist", + "src", + "index.js", + ); + return existsSync(candidate) ? candidate : null; +} + +async function searchFffEntries(input: { + finder: FileFinder; + query: string; + includeDirectories: boolean; + includeFiles: boolean; +}): Promise { + if (input.includeDirectories && input.includeFiles) { + const result = unwrapFffResult( + input.finder.mixedSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }), + "Workspace mixed search failed", + ); + return result.items.map((item) => normalizeMixedItem(item)); + } + + if (input.includeFiles) { + const result = unwrapFffResult( + input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }), + "Workspace file search failed", + ); + return result.items.map((item) => normalizeFileItem(item)); + } + + const result = unwrapFffResult( + input.finder.directorySearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }), + "Workspace directory search failed", + ); + const fileResult = unwrapFffResult( + input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }), + "Workspace file search failed", + ); + return [ + ...result.items.map((item) => normalizeDirectoryItem(item)), + ...fileResult.items.map((item) => normalizeFileItem(item)), + ]; +} + +function unwrapFffResult(result: Result, message: string): T { + if (result.ok) { + return result.value; + } + throw new Error(`${message}: ${result.error}`); +} + +function normalizeMixedItem(item: MixedItem): WorkspaceSuggestionEntry { + return item.type === "file" ? normalizeFileItem(item.item) : normalizeDirectoryItem(item.item); +} + +function normalizeFileItem(item: FileItem): WorkspaceSuggestionEntry { + return { + path: normalizeFffRelativePath(item.relativePath), + kind: "file", + }; +} + +function normalizeDirectoryItem(item: DirItem): WorkspaceSuggestionEntry { + return { + path: normalizeFffRelativePath(item.relativePath).replace(/\/+$/, ""), + kind: "directory", + }; +} + +async function normalizeFffEntries(input: { + workspaceRoot: string; + candidates: WorkspaceSuggestionEntry[]; + includeDirectories: boolean; + includeFiles: boolean; + matchMode: WorkspaceMatchMode; + suffixQuery: string; +}): Promise { + const entries: WorkspaceSuggestionEntry[] = []; + for (const candidate of input.candidates) { + for (const entry of expandFffCandidate({ + candidate, + includeDirectories: input.includeDirectories, + matchMode: input.matchMode, + query: input.suffixQuery, + })) { + if (entry.kind === "directory" && !input.includeDirectories) { + continue; + } + if (entry.kind === "file" && !input.includeFiles) { + continue; + } + if ( + input.matchMode === "suffix" && + !workspaceEntryMatchesSuffixQuery({ + relativePath: entry.path, + query: input.suffixQuery, + }) + ) { + continue; + } + const resolved = await resolveIndexedWorkspaceEntry({ + workspaceRoot: input.workspaceRoot, + relativePath: entry.path, + expectedKind: entry.kind, + }); + if (resolved) { + entries.push(resolved); + } + } + } + return entries; +} + +function expandFffCandidate(input: { + candidate: WorkspaceSuggestionEntry; + includeDirectories: boolean; + matchMode: WorkspaceMatchMode; + query: string; +}): WorkspaceSuggestionEntry[] { + if (!input.includeDirectories || input.candidate.kind !== "file") { + return [input.candidate]; + } + + return [ + ...collectMatchingAncestorDirectories({ + relativePath: input.candidate.path, + matchMode: input.matchMode, + query: input.query, + }), + input.candidate, + ]; +} + +function collectMatchingAncestorDirectories(input: { + relativePath: string; + matchMode: WorkspaceMatchMode; + query: string; +}): WorkspaceSuggestionEntry[] { + const segments = normalizeFffRelativePath(input.relativePath).split("/").filter(Boolean); + const directories: WorkspaceSuggestionEntry[] = []; + for (let segmentCount = 1; segmentCount < segments.length; segmentCount += 1) { + const directoryPath = segments.slice(0, segmentCount).join("/"); + if ( + workspaceDirectoryMatchesQuery({ + relativePath: directoryPath, + matchMode: input.matchMode, + query: input.query, + }) + ) { + directories.push({ path: directoryPath, kind: "directory" }); + } + } + return directories; +} + +function workspaceDirectoryMatchesQuery(input: { + relativePath: string; + matchMode: WorkspaceMatchMode; + query: string; +}): boolean { + const normalizedQuery = normalizeFffRelativePath(input.query) + .trim() + .replace(/^\.\/+/, "") + .toLowerCase(); + if (!normalizedQuery) { + return true; + } + if (input.matchMode === "suffix") { + return workspaceEntryMatchesSuffixQuery({ + relativePath: input.relativePath, + query: normalizedQuery, + }); + } + return normalizeFffRelativePath(input.relativePath).toLowerCase().includes(normalizedQuery); +} + +async function resolveIndexedWorkspaceEntry(input: { + workspaceRoot: string; + relativePath: string; + expectedKind: WorkspaceSuggestionKind; +}): Promise { + const candidatePath = path.resolve(input.workspaceRoot, input.relativePath); + if (!isPathInsideRoot(input.workspaceRoot, candidatePath)) { + return null; + } + + let resolvedPath: string; + try { + resolvedPath = await realpath(candidatePath); + } catch { + return null; + } + if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) { + return null; + } + + const stats = await stat(resolvedPath).catch(() => null); + if (!stats) { + return null; + } + if (input.expectedKind === "file" && !stats.isFile()) { + return null; + } + if (input.expectedKind === "directory" && !stats.isDirectory()) { + return null; + } + + return { + path: normalizeRelativePath(input.workspaceRoot, candidatePath), + kind: input.expectedKind, + }; +} + +async function resolveWorkspaceExactEntry(input: { + workspaceRoot: string; + query: string; + includeDirectories: boolean; + includeFiles: boolean; +}): Promise { + const normalized = normalizeFffRelativePath(input.query) + .trim() + .replace(/^\.\/+/, "") + .replace(/\/{2,}/g, "/"); + if (!normalized) { + return null; + } + + const candidatePath = path.isAbsolute(normalized) + ? path.resolve(normalized) + : path.resolve(input.workspaceRoot, normalized); + let resolvedPath: string; + try { + resolvedPath = await realpath(candidatePath); + } catch { + return null; + } + if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) { + return null; + } + + const stats = await stat(resolvedPath).catch(() => null); + if (!stats) { + return null; + } + if (stats.isFile() && input.includeFiles) { + return { + path: normalizeRelativePath(input.workspaceRoot, candidatePath), + kind: "file", + }; + } + if (stats.isDirectory() && input.includeDirectories) { + return { + path: normalizeRelativePath(input.workspaceRoot, candidatePath), + kind: "directory", + }; + } + return null; +} + +function prependWorkspaceEntry( + entry: WorkspaceSuggestionEntry, + entries: WorkspaceSuggestionEntry[], +): WorkspaceSuggestionEntry[] { + return [ + entry, + ...entries.filter( + (candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path, + ), + ]; +} + +function dedupeWorkspaceEntries(entries: WorkspaceSuggestionEntry[]): WorkspaceSuggestionEntry[] { + const seen = new Set(); + const deduped: WorkspaceSuggestionEntry[] = []; + for (const entry of entries) { + const key = `${entry.kind}:${entry.path}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(entry); + } + return deduped; +} + +function workspaceEntryMatchesSuffixQuery(input: { relativePath: string; query: string }): boolean { + const querySegments = normalizeFffRelativePath(input.query) + .trim() + .replace(/^\.\/+/, "") + .split("/") + .filter(Boolean) + .map((segment) => segment.toLowerCase()); + if (querySegments.length === 0) { + return false; + } + + const pathSegments = normalizeFffRelativePath(input.relativePath) + .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 normalizeWorkspaceQueryParts( + query: string, + workspaceRoot: string, +): { parentPart: string; searchTerm: string } | null { + let normalized = normalizeFffRelativePath(query.trim()); + + if (path.isAbsolute(normalized)) { + const absolute = path.resolve(normalized); + if (!isPathInsideRoot(workspaceRoot, absolute)) { + return null; + } + normalized = normalizeRelativePath(workspaceRoot, absolute); + } + + normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); + if (!normalized) { + return { + parentPart: "", + searchTerm: "", + }; + } + + const slashIndex = normalized.lastIndexOf("/"); + return { + parentPart: slashIndex >= 0 ? normalized.slice(0, slashIndex) : "", + searchTerm: slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized, + }; +} + +function normalizeLimit(limit: number | undefined): number { + const candidate = limit ?? DEFAULT_LIMIT; + if (!Number.isFinite(candidate)) { + return DEFAULT_LIMIT; + } + const bounded = Math.trunc(candidate); + return Math.max(1, Math.min(MAX_LIMIT, bounded)); +} + +async function resolveDirectory(inputPath: string): Promise { + try { + const resolved = await realpath(path.resolve(inputPath)); + const stats = await stat(resolved); + if (!stats.isDirectory()) { + return null; + } + return resolved; + } catch { + return null; + } +} + +function normalizeRelativePath(root: string, absolutePath: string): string { + const relative = path.relative(root, absolutePath); + if (!relative) { + return "."; + } + return relative.split(path.sep).join("/"); +} + +function normalizeFffRelativePath(value: string): string { + return value.replace(/\\/g, "/"); +} + +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) { + workspaceFinderCache.delete(cacheKey); + destroyFinder(entry.finder); + } + } + + while (workspaceFinderCache.size > FFF_FINDER_CACHE_MAX_ENTRIES) { + const oldestKey = workspaceFinderCache.keys().next().value; + if (!oldestKey) { + return; + } + const oldest = workspaceFinderCache.get(oldestKey); + workspaceFinderCache.delete(oldestKey); + if (oldest) { + destroyFinder(oldest.finder); + } + } +} + +function destroyFinder(finder: FileFinder): void { + if (finder.isDestroyed) { + return; + } + try { + finder.destroy(); + } catch { + // Destroy is best-effort cleanup for native handles. + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4e4f4c1bc..f20f54bd5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -174,7 +174,8 @@ import { type AgentUpdatesService, } from "./session/agent-updates/agent-updates-service.js"; import { expandTilde } from "../utils/path.js"; -import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; +import { searchHomeDirectories } from "./search/home-directories.js"; +import { searchWorkspaceEntries } from "./search/workspace-entries.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts deleted file mode 100644 index 36cfa7ef8..000000000 --- a/packages/server/src/utils/directory-suggestions.ts +++ /dev/null @@ -1,1126 +0,0 @@ -import type { Dirent } from "node:fs"; -import { readdir, realpath, stat } from "node:fs/promises"; -import path from "node:path"; -import { isPathInsideRoot } from "./path.js"; - -export interface SearchHomeDirectoriesOptions { - homeDir: string; - query: string; - limit?: number; - maxDepth?: number; - maxDirectoriesScanned?: number; -} - -export type WorkspaceSuggestionKind = "file" | "directory"; - -export interface WorkspaceSuggestionEntry { - path: string; - kind: WorkspaceSuggestionKind; -} - -export interface SearchWorkspaceEntriesOptions { - cwd: string; - query: string; - 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 = 12; -const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000; -const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; -const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; - -interface QueryParts { - isPathQuery: boolean; - parentPart: string; - searchTerm: string; -} - -interface RankedDirectory { - absolutePath: string; - matchTier: number; - segmentIndex: number; - matchOffset: number; - depth: number; -} - -interface ChildDirectoryEntry { - name: string; - absolutePath: string; -} - -interface ChildWorkspaceEntry { - name: string; - absolutePath: string; - kind: WorkspaceSuggestionKind; -} - -interface DirectoryListCacheEntry { - expiresAt: number; - entries: ChildDirectoryEntry[]; -} - -interface WorkspaceEntryListCacheEntry { - expiresAt: number; - entries: ChildWorkspaceEntry[]; -} - -const directoryListCache = new Map(); -const workspaceEntryListCache = new Map(); -const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; -const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; -const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; -const NO_WORKSPACE_MATCH_TIER = 5; -const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ - "node_modules", - "venv", - "env", - "virtualenv", - "dist", - "build", - "target", - "out", - "coverage", - "vendor", - "__pycache__", - ".git", -]); -const TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES = new Set([ - ".agents", - ".claude", - ".codex", - ".github", - ".paseo", - ".vscode", -]); - -export async function searchHomeDirectories( - options: SearchHomeDirectoriesOptions, -): Promise { - const query = options.query.trim(); - if (!query) { - return []; - } - - const limit = normalizeLimit(options.limit); - const homeRoot = await resolveDirectory(options.homeDir); - if (!homeRoot) { - return []; - } - - const queryParts = normalizeQueryParts(query, homeRoot); - if (!queryParts) { - return []; - } - - if (queryParts.isPathQuery) { - return searchWithinParentDirectory({ - homeRoot, - parentPart: queryParts.parentPart, - searchTerm: queryParts.searchTerm, - limit, - }); - } - - return searchAcrossHomeTree({ - homeRoot, - searchTerm: queryParts.searchTerm, - limit, - maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, - maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, - }); -} - -interface RankedWorkspaceEntry { - relativePath: string; - kind: WorkspaceSuggestionKind; - matchTier: number; - segmentIndex: number; - matchOffset: number; - fuzzyScore: number; - depth: number; -} - -export async function searchWorkspaceEntries( - options: SearchWorkspaceEntriesOptions, -): Promise { - const limit = normalizeLimit(options.limit); - const includeDirectories = options.includeDirectories ?? true; - const includeFiles = options.includeFiles ?? false; - if (!includeDirectories && !includeFiles) { - return []; - } - - const workspaceRoot = await resolveDirectory(options.cwd); - if (!workspaceRoot) { - return []; - } - - const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot); - if (!queryParts) { - return []; - } - - const matchMode = options.matchMode ?? "fuzzy"; - const exactEntry = - queryParts.isPathQuery && matchMode === "suffix" - ? await resolveWorkspaceExactEntry({ - workspaceRoot, - query: options.query, - includeDirectories, - includeFiles, - }) - : null; - if (exactEntry && limit <= 1) { - return [exactEntry]; - } - - if (queryParts.isPathQuery && matchMode !== "suffix") { - return searchWorkspaceWithinParentDirectory({ - workspaceRoot, - parentPart: queryParts.parentPart, - searchTerm: queryParts.searchTerm, - limit, - includeDirectories, - includeFiles, - }); - } - - const searchTerm = - matchMode === "suffix" - ? [queryParts.parentPart, queryParts.searchTerm].filter(Boolean).join("/") - : queryParts.searchTerm; - const entries = await searchWorkspaceAcrossTree({ - workspaceRoot, - searchTerm, - limit, - includeDirectories, - includeFiles, - matchMode, - maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, - maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED, - }); - return exactEntry ? prependWorkspaceEntry(exactEntry, entries).slice(0, limit) : entries; -} - -async function resolveWorkspaceExactEntry(input: { - workspaceRoot: string; - query: string; - includeDirectories: boolean; - includeFiles: boolean; -}): Promise { - const normalized = input.query - .trim() - .replace(/\\/g, "/") - .replace(/^\.\/+/, "") - .replace(/\/{2,}/g, "/"); - if (!normalized) { - return null; - } - - const candidatePath = path.isAbsolute(normalized) - ? path.resolve(normalized) - : path.resolve(input.workspaceRoot, normalized); - let resolvedPath: string; - try { - resolvedPath = await realpath(candidatePath); - } catch { - return null; - } - if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) { - return null; - } - - const stats = await stat(resolvedPath).catch(() => null); - if (!stats) { - return null; - } - if (stats.isFile() && input.includeFiles) { - return { - path: normalizeRelativePath(input.workspaceRoot, resolvedPath), - kind: "file", - }; - } - if (stats.isDirectory() && input.includeDirectories) { - return { - path: normalizeRelativePath(input.workspaceRoot, resolvedPath), - kind: "directory", - }; - } - return null; -} - -function prependWorkspaceEntry( - entry: WorkspaceSuggestionEntry, - entries: WorkspaceSuggestionEntry[], -): WorkspaceSuggestionEntry[] { - return [ - entry, - ...entries.filter( - (candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path, - ), - ]; -} - -function normalizeLimit(limit: number | undefined): number { - const candidate = limit ?? DEFAULT_LIMIT; - if (!Number.isFinite(candidate)) { - return DEFAULT_LIMIT; - } - const bounded = Math.trunc(candidate); - return Math.max(1, Math.min(MAX_LIMIT, bounded)); -} - -async function searchWithinParentDirectory(input: { - homeRoot: string; - parentPart: string; - searchTerm: string; - limit: number; -}): Promise { - const parentPath = path.resolve(input.homeRoot, input.parentPart || "."); - const parentRoot = await resolveDirectory(parentPath); - if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) { - return []; - } - - const searchLower = input.searchTerm.toLowerCase(); - const ranked: RankedDirectory[] = []; - const entries = await listChildDirectories({ - directory: parentRoot, - homeRoot: input.homeRoot, - }); - - for (const entry of entries) { - if (searchLower && !entry.name.toLowerCase().includes(searchLower)) { - continue; - } - - ranked.push( - rankDirectory({ - absolutePath: entry.absolutePath, - homeRoot: input.homeRoot, - searchLower, - }), - ); - } - - return dedupeAndSort(ranked).slice(0, input.limit); -} - -async function searchAcrossHomeTree(input: { - homeRoot: string; - searchTerm: string; - limit: number; - maxDepth: number; - maxDirectoriesScanned: number; -}): Promise { - const queue: Array<{ directory: string; depth: number }> = [ - { directory: input.homeRoot, depth: 0 }, - ]; - const visited = new Set([input.homeRoot]); - const ranked: RankedDirectory[] = []; - let scanned = 0; - const searchLower = input.searchTerm.toLowerCase(); - - for ( - let queueIndex = 0; - queueIndex < queue.length && scanned < input.maxDirectoriesScanned; - queueIndex += 1 - ) { - const current = queue[queueIndex]; - if (!current) continue; - const entries = await listChildDirectories({ - directory: current.directory, - homeRoot: input.homeRoot, - }); - - for (const entry of entries) { - const resolvedCandidate = entry.absolutePath; - if (visited.has(resolvedCandidate)) { - continue; - } - visited.add(resolvedCandidate); - scanned += 1; - - const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate); - if ( - relativePath.toLowerCase().includes(searchLower) || - entry.name.toLowerCase().includes(searchLower) - ) { - ranked.push( - rankDirectory({ - absolutePath: resolvedCandidate, - homeRoot: input.homeRoot, - searchLower, - }), - ); - } - - if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) { - queue.push({ directory: resolvedCandidate, depth: current.depth + 1 }); - } - } - } - - return dedupeAndSort(ranked).slice(0, input.limit); -} - -async function searchWorkspaceWithinParentDirectory(input: { - workspaceRoot: string; - parentPart: string; - searchTerm: string; - limit: number; - includeDirectories: boolean; - includeFiles: boolean; -}): Promise { - const parentPath = path.resolve(input.workspaceRoot, input.parentPart || "."); - const parentRoot = await resolveDirectory(parentPath); - if (!parentRoot || !isPathInsideRoot(input.workspaceRoot, parentRoot)) { - return []; - } - - const searchLower = input.searchTerm.toLowerCase(); - const ranked: RankedWorkspaceEntry[] = []; - const entries = await listWorkspaceChildEntries({ - directory: parentRoot, - workspaceRoot: input.workspaceRoot, - }); - - for (const entry of entries) { - if (entry.kind === "directory" && !input.includeDirectories) { - continue; - } - if (entry.kind === "file" && !input.includeFiles) { - continue; - } - if (isHiddenWorkspaceSuggestion(entry)) { - continue; - } - const rankedEntry = rankWorkspaceEntry({ - absolutePath: entry.absolutePath, - kind: entry.kind, - workspaceRoot: input.workspaceRoot, - searchLower, - }); - if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) { - continue; - } - - ranked.push(rankedEntry); - } - - return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit); -} - -async function searchWorkspaceAcrossTree(input: { - workspaceRoot: string; - searchTerm: string; - limit: number; - includeDirectories: boolean; - includeFiles: boolean; - matchMode: WorkspaceMatchMode; - maxDepth: number; - maxEntriesScanned: number; -}): Promise { - const queue: Array<{ directory: string; depth: number }> = [ - { directory: input.workspaceRoot, depth: 0 }, - ]; - const visited = new Set([input.workspaceRoot]); - const ranked: RankedWorkspaceEntry[] = []; - let scanned = 0; - const searchLower = input.searchTerm.toLowerCase(); - - for ( - let queueIndex = 0; - queueIndex < queue.length && scanned < input.maxEntriesScanned; - queueIndex += 1 - ) { - const current = queue[queueIndex]; - if (!current) continue; - - const entries = await listWorkspaceChildEntries({ - directory: current.directory, - workspaceRoot: input.workspaceRoot, - }); - - for (const entry of entries) { - scanned += 1; - - if (entry.kind === "directory") { - if ( - !visited.has(entry.absolutePath) && - current.depth < input.maxDepth && - scanned < input.maxEntriesScanned - ) { - visited.add(entry.absolutePath); - queue.push({ - directory: entry.absolutePath, - depth: current.depth + 1, - }); - } - } - - if (entry.kind === "directory" && !input.includeDirectories) { - continue; - } - // Hidden directories are traversed, but not offered as suggestions. - if (isHiddenWorkspaceSuggestion(entry)) { - continue; - } - 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, - kind: entry.kind, - workspaceRoot: input.workspaceRoot, - searchLower, - }); - if ( - input.matchMode !== "suffix" && - searchLower && - rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER - ) { - continue; - } - - ranked.push(rankedEntry); - } - } - - 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[] { - const byPath = new Map(); - for (const entry of rankedEntries) { - const key = `${entry.kind}:${entry.relativePath}`; - const existing = byPath.get(key); - if (!existing || compareRankedWorkspaceEntries(entry, existing) < 0) { - byPath.set(key, entry); - } - } - - return Array.from(byPath.values()) - .sort(compareRankedWorkspaceEntries) - .map((entry) => ({ - path: entry.relativePath, - kind: entry.kind, - })); -} - -function compareRankedWorkspaceEntries( - left: RankedWorkspaceEntry, - right: RankedWorkspaceEntry, -): number { - if (left.matchTier !== right.matchTier) { - return left.matchTier - right.matchTier; - } - if (left.segmentIndex !== right.segmentIndex) { - return left.segmentIndex - right.segmentIndex; - } - if (left.matchOffset !== right.matchOffset) { - return left.matchOffset - right.matchOffset; - } - if (left.fuzzyScore !== right.fuzzyScore) { - return left.fuzzyScore - right.fuzzyScore; - } - if (left.depth !== right.depth) { - return left.depth - right.depth; - } - if (left.kind !== right.kind) { - return left.kind === "directory" ? -1 : 1; - } - return left.relativePath.localeCompare(right.relativePath); -} - -function dedupeAndSort(ranked: RankedDirectory[]): string[] { - const byPath = new Map(); - for (const entry of ranked) { - const existing = byPath.get(entry.absolutePath); - if (!existing || compareRankedDirectories(entry, existing) < 0) { - byPath.set(entry.absolutePath, entry); - } - } - - return Array.from(byPath.values()) - .sort(compareRankedDirectories) - .map((entry) => entry.absolutePath); -} - -function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number { - if (left.matchTier !== right.matchTier) { - return left.matchTier - right.matchTier; - } - if (left.segmentIndex !== right.segmentIndex) { - return left.segmentIndex - right.segmentIndex; - } - if (left.matchOffset !== right.matchOffset) { - return left.matchOffset - right.matchOffset; - } - if (left.depth !== right.depth) { - return left.depth - right.depth; - } - return left.absolutePath.localeCompare(right.absolutePath); -} - -function rankDirectory(input: { - absolutePath: string; - homeRoot: string; - searchLower: string; -}): RankedDirectory { - const relative = normalizeRelativePath(input.homeRoot, input.absolutePath); - const relativeLower = relative.toLowerCase(); - const depth = relative === "." ? 0 : relative.split("/").length; - const searchLower = input.searchLower; - if (!searchLower) { - return { - absolutePath: input.absolutePath, - matchTier: 3, - segmentIndex: NO_SEGMENT_INDEX, - matchOffset: 0, - depth, - }; - } - const segments = relativeLower === "." ? [] : relativeLower.split("/"); - const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower); - const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.startsWith(searchLower), - ); - const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.includes(searchLower), - ); - const matchOffset = relativeLower.indexOf(searchLower); - let matchTier = 4; - let segmentIndex = NO_SEGMENT_INDEX; - - if (exactSegmentIndex >= 0) { - matchTier = 0; - segmentIndex = exactSegmentIndex; - } else if (prefixSegmentIndex >= 0) { - matchTier = 1; - segmentIndex = prefixSegmentIndex; - } else if (partialSegmentIndex >= 0) { - matchTier = 2; - segmentIndex = partialSegmentIndex; - } else if (relativeLower.startsWith(searchLower)) { - matchTier = 3; - } - - return { - absolutePath: input.absolutePath, - matchTier, - segmentIndex, - matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, - depth, - }; -} - -function rankWorkspaceEntry(input: { - absolutePath: string; - kind: WorkspaceSuggestionKind; - workspaceRoot: string; - searchLower: string; -}): RankedWorkspaceEntry { - const relativePath = normalizeRelativePath(input.workspaceRoot, input.absolutePath); - const relativeLower = relativePath.toLowerCase(); - const depth = relativePath === "." ? 0 : relativePath.split("/").length; - const searchLower = input.searchLower; - if (!searchLower) { - return { - relativePath, - kind: input.kind, - matchTier: 3, - segmentIndex: NO_SEGMENT_INDEX, - matchOffset: 0, - fuzzyScore: NO_FUZZY_SCORE, - depth, - }; - } - - const segments = relativeLower === "." ? [] : relativeLower.split("/"); - const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower); - const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.startsWith(searchLower), - ); - const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) => - segment.includes(searchLower), - ); - const matchOffset = relativeLower.indexOf(searchLower); - const basename = segments.at(-1) ?? ""; - const fuzzyScore = scoreFuzzySubsequence(searchLower, basename); - let matchTier = NO_WORKSPACE_MATCH_TIER; - let segmentIndex = NO_SEGMENT_INDEX; - - if (exactSegmentIndex >= 0) { - matchTier = 0; - segmentIndex = exactSegmentIndex; - } else if (prefixSegmentIndex >= 0) { - matchTier = 1; - segmentIndex = prefixSegmentIndex; - } else if (partialSegmentIndex >= 0) { - matchTier = 2; - segmentIndex = partialSegmentIndex; - } else if (relativeLower.startsWith(searchLower)) { - matchTier = 3; - } else if (fuzzyScore !== null) { - matchTier = 4; - } - - return { - relativePath, - kind: input.kind, - matchTier, - segmentIndex, - matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET, - fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE, - depth, - }; -} - -function scoreFuzzySubsequence(query: string, candidate: string): number | null { - if (!query) { - return 0; - } - - let queryIndex = 0; - let firstMatchIndex = -1; - let previousMatchIndex = -1; - let gapScore = 0; - - for ( - let candidateIndex = 0; - candidateIndex < candidate.length && queryIndex < query.length; - candidateIndex += 1 - ) { - if (candidate[candidateIndex] !== query[queryIndex]) { - continue; - } - - if (firstMatchIndex === -1) { - firstMatchIndex = candidateIndex; - } - if (previousMatchIndex >= 0) { - gapScore += candidateIndex - previousMatchIndex - 1; - } - previousMatchIndex = candidateIndex; - queryIndex += 1; - } - - if (queryIndex !== query.length || firstMatchIndex === -1) { - return null; - } - - return firstMatchIndex + gapScore; -} - -function findSegmentMatchIndex( - segments: string[], - predicate: (segment: string) => boolean, -): number { - for (let index = 0; index < segments.length; index += 1) { - const segment = segments[index]; - if (!segment) { - continue; - } - if (predicate(segment)) { - return index; - } - } - return -1; -} - -function normalizeRelativePath(homeRoot: string, absolutePath: string): string { - const relative = path.relative(homeRoot, absolutePath); - if (!relative) { - return "."; - } - return relative.split(path.sep).join("/"); -} - -function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null { - const typedQuery = query.trim().replace(/\\/g, "/"); - let normalized = typedQuery; - if (!normalized) { - return null; - } - - // Only treat the query as a literal path when the user explicitly roots it - // with ~, ~/, ./, or an absolute path. Bare queries like "faro/main" are - // search terms, not paths. - let isRooted = false; - - if (normalized.startsWith("~")) { - isRooted = true; - normalized = normalized.slice(1); - if (normalized.startsWith("/")) { - normalized = normalized.slice(1); - } - } - - if (path.isAbsolute(normalized)) { - isRooted = true; - const absolute = path.resolve(normalized); - if (!isPathInsideRoot(homeRoot, absolute)) { - return null; - } - normalized = normalizeRelativePath(homeRoot, absolute); - } - - if (normalized.startsWith("./")) { - isRooted = true; - } - normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); - if (!normalized) { - // Treat "~" and "~/" as a request to browse the home root. - if (typedQuery === "~" || typedQuery === "~/") { - return { - isPathQuery: true, - parentPart: "", - searchTerm: "", - }; - } - return null; - } - - const isPathQuery = isRooted && normalized.includes("/"); - if (!isPathQuery) { - return { - isPathQuery: false, - parentPart: "", - searchTerm: normalized, - }; - } - - const slashIndex = normalized.lastIndexOf("/"); - const parentPart = normalized.slice(0, slashIndex); - const searchTerm = normalized.slice(slashIndex + 1); - - return { - isPathQuery: true, - parentPart, - searchTerm, - }; -} - -function normalizeWorkspaceQueryParts(query: string, workspaceRoot: string): QueryParts | null { - let normalized = query.trim().replace(/\\/g, "/"); - - if (path.isAbsolute(normalized)) { - const absolute = path.resolve(normalized); - if (!isPathInsideRoot(workspaceRoot, absolute)) { - return null; - } - normalized = normalizeRelativePath(workspaceRoot, absolute); - } - - normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/"); - if (!normalized) { - return { - isPathQuery: true, - parentPart: "", - searchTerm: "", - }; - } - - const isPathQuery = normalized.includes("/"); - const slashIndex = normalized.lastIndexOf("/"); - const parentPart = slashIndex >= 0 ? normalized.slice(0, slashIndex) : ""; - const searchTerm = slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; - - return { - isPathQuery, - parentPart, - searchTerm, - }; -} - -async function resolveDirectory(inputPath: string): Promise { - try { - const resolved = await realpath(path.resolve(inputPath)); - const stats = await stat(resolved); - if (!stats.isDirectory()) { - return null; - } - return resolved; - } catch { - return null; - } -} - -async function listChildDirectories(input: { - directory: string; - homeRoot: string; -}): Promise { - const now = Date.now(); - const cached = directoryListCache.get(input.directory); - if (cached && cached.expiresAt > now) { - return cached.entries; - } - - const dirents = await readdir(input.directory, { withFileTypes: true }).catch( - () => [] as Dirent[], - ); - const candidates = dirents.filter( - (dirent) => - !isHiddenDirectoryName(dirent.name) && - !isIgnoredSuggestionDirectoryName(dirent.name) && - (dirent.isDirectory() || dirent.isSymbolicLink()), - ); - const resolved = await Promise.all( - candidates.map(async (dirent) => { - const candidatePath = path.join(input.directory, dirent.name); - const absolutePath = await resolveDirectoryCandidate({ - candidatePath, - dirent, - homeRoot: input.homeRoot, - }); - return absolutePath ? { name: dirent.name, absolutePath } : null; - }), - ); - const entries: ChildDirectoryEntry[] = resolved.filter( - (entry): entry is ChildDirectoryEntry => entry !== null, - ); - - setDirectoryListCache(input.directory, { - expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, - entries, - }); - - return entries; -} - -async function listWorkspaceChildEntries(input: { - directory: string; - workspaceRoot: string; -}): Promise { - const now = Date.now(); - const cached = workspaceEntryListCache.get(input.directory); - if (cached && cached.expiresAt > now) { - return cached.entries; - } - - const dirents = await readdir(input.directory, { withFileTypes: true }).catch( - () => [] as Dirent[], - ); - const candidates = dirents.filter((dirent) => { - if (isIgnoredSuggestionDirectoryName(dirent.name)) { - return false; - } - if ( - isHiddenDirectoryName(dirent.name) && - !dirent.isFile() && - !isTraversableHiddenWorkspaceDirectoryName(dirent.name) - ) { - return false; - } - // Allowlisted hidden directories remain traversable so file links like - // `.claude/settings.local.json` can resolve, but hidden files (e.g. - // `.DS_Store`) should never be suggested. - if (dirent.isFile() && isHiddenDirectoryName(dirent.name)) { - return false; - } - return true; - }); - - const resolved = await Promise.all( - candidates.map(async (dirent) => { - const candidatePath = path.join(input.directory, dirent.name); - const entry = await resolveWorkspaceCandidate({ - candidatePath, - dirent, - workspaceRoot: input.workspaceRoot, - }); - return entry - ? { name: dirent.name, absolutePath: entry.absolutePath, kind: entry.kind } - : null; - }), - ); - const entries: ChildWorkspaceEntry[] = resolved.filter( - (entry): entry is ChildWorkspaceEntry => entry !== null, - ); - - setWorkspaceEntryListCache(input.directory, { - expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS, - entries, - }); - - return entries; -} - -async function resolveDirectoryCandidate(input: { - candidatePath: string; - dirent: Dirent; - homeRoot: string; -}): Promise { - if (input.dirent.isDirectory()) { - const resolved = path.resolve(input.candidatePath); - return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null; - } - - const resolved = await resolveDirectory(input.candidatePath); - if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) { - return null; - } - return resolved; -} - -async function resolveWorkspaceCandidate(input: { - candidatePath: string; - dirent: Dirent; - workspaceRoot: string; -}): Promise<{ absolutePath: string; kind: WorkspaceSuggestionKind } | null> { - if (input.dirent.isDirectory()) { - const resolved = path.resolve(input.candidatePath); - if (!isPathInsideRoot(input.workspaceRoot, resolved)) { - return null; - } - return { absolutePath: resolved, kind: "directory" }; - } - - if (input.dirent.isFile()) { - const resolved = path.resolve(input.candidatePath); - if (!isPathInsideRoot(input.workspaceRoot, resolved)) { - return null; - } - return { absolutePath: resolved, kind: "file" }; - } - - if (!input.dirent.isSymbolicLink()) { - return null; - } - - try { - const resolved = await realpath(input.candidatePath); - if (!isPathInsideRoot(input.workspaceRoot, resolved)) { - return null; - } - const stats = await stat(resolved); - if (stats.isDirectory()) { - return { absolutePath: resolved, kind: "directory" }; - } - if (stats.isFile()) { - return { absolutePath: resolved, kind: "file" }; - } - return null; - } catch { - return null; - } -} - -function isHiddenDirectoryName(name: string): boolean { - return name.startsWith("."); -} - -function isHiddenWorkspaceSuggestion(entry: ChildWorkspaceEntry): boolean { - return isHiddenDirectoryName(entry.name); -} - -function isIgnoredSuggestionDirectoryName(name: string): boolean { - return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name); -} - -function isTraversableHiddenWorkspaceDirectoryName(name: string): boolean { - return TRAVERSABLE_HIDDEN_WORKSPACE_DIRECTORY_NAMES.has(name); -} - -function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void { - directoryListCache.set(cacheKey, entry); - pruneDirectoryListCache(); -} - -function setWorkspaceEntryListCache(cacheKey: string, entry: WorkspaceEntryListCacheEntry): void { - workspaceEntryListCache.set(cacheKey, entry); - pruneWorkspaceEntryListCache(); -} - -function pruneDirectoryListCache(): void { - if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - return; - } - - const now = Date.now(); - for (const [cacheKey, entry] of directoryListCache) { - if (entry.expiresAt <= now) { - directoryListCache.delete(cacheKey); - } - } - - while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - const oldestKey = directoryListCache.keys().next().value; - if (!oldestKey) { - return; - } - directoryListCache.delete(oldestKey); - } -} - -function pruneWorkspaceEntryListCache(): void { - if (workspaceEntryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - return; - } - - const now = Date.now(); - for (const [cacheKey, entry] of workspaceEntryListCache) { - if (entry.expiresAt <= now) { - workspaceEntryListCache.delete(cacheKey); - } - } - - while (workspaceEntryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { - const oldestKey = workspaceEntryListCache.keys().next().value; - if (!oldestKey) { - return; - } - workspaceEntryListCache.delete(oldestKey); - } -} diff --git a/scripts/trace-daemon.mjs b/scripts/trace-daemon.mjs index 5b7de8b41..7a8cc7fd4 100644 --- a/scripts/trace-daemon.mjs +++ b/scripts/trace-daemon.mjs @@ -27,6 +27,29 @@ const entries = [ "packages/server/dist/server/terminal/terminal-worker-process.js", ]; +const FFF_BIN_PACKAGE = { + darwin: { arm64: "@ff-labs/fff-bin-darwin-arm64", x64: "@ff-labs/fff-bin-darwin-x64" }, + linux: { arm64: "@ff-labs/fff-bin-linux-arm64-gnu", x64: "@ff-labs/fff-bin-linux-x64-gnu" }, + win32: { arm64: "@ff-labs/fff-bin-win32-arm64", x64: "@ff-labs/fff-bin-win32-x64" }, +}; + +const FFI_RS_PACKAGE = { + darwin: { arm64: "@yuuang/ffi-rs-darwin-arm64", x64: "@yuuang/ffi-rs-darwin-x64" }, + linux: { arm64: "@yuuang/ffi-rs-linux-arm64-gnu", x64: "@yuuang/ffi-rs-linux-x64-gnu" }, + win32: { + arm64: "@yuuang/ffi-rs-win32-arm64-msvc", + x64: "@yuuang/ffi-rs-win32-x64-msvc", + }, +}; + +function packageGlob(packageName) { + return packageName ? [`node_modules/${packageName}/**`] : []; +} + +function packageForHost(packagesByPlatform) { + return packagesByPlatform[process.platform]?.[process.arch] ?? null; +} + // Files read at runtime via fs APIs rather than `require`. nft only // traces the module graph; data files have to be listed explicitly. const additionalInputs = [ @@ -44,6 +67,11 @@ const additionalInputs = [ // the Nix derivation builds for one platform at a time and ships only // its own binaries. `node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`, + // FFF resolves its native search library and ffi-rs binding dynamically. + "node_modules/@ff-labs/fff-node/**", + "node_modules/ffi-rs/**", + ...packageGlob(packageForHost(FFF_BIN_PACKAGE)), + ...packageGlob(packageForHost(FFI_RS_PACKAGE)), ]; // Trace. @@ -65,6 +93,10 @@ const { fileList, warnings } = await nodeFileTrace(entries, { // node-fetch optional peer for non-UTF-8 charset decoding; not // loaded in our usage. "encoding/**", + // Host packages are added explicitly above; other optional native + // variants are intentionally not part of the daemon closure. + "@ff-labs/fff-bin-*/**", + "@yuuang/ffi-rs-*/**", // Tests are stripped during the daemon build; nft sometimes still // tries to walk into them via index files. Belt and suspenders. "**/*.test.js",