diff --git a/esbuild.config.js b/esbuild.config.js
index e84b6223bd2..7a7e2e54ce7 100644
--- a/esbuild.config.js
+++ b/esbuild.config.js
@@ -72,45 +72,65 @@ const external = [
'@teddyzhu/clipboard-win32-arm64-msvc',
];
-esbuild
- .build({
- entryPoints: ['packages/cli/index.ts'],
- bundle: true,
- outfile: 'dist/cli.js',
- platform: 'node',
- format: 'esm',
- target: 'node20',
- external,
- packages: 'bundle',
- inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')],
- banner: {
- js: `// Force strict mode and setup for ESM
+const commonBundleOptions = {
+ bundle: true,
+ platform: 'node',
+ format: 'esm',
+ target: 'node20',
+ external,
+ packages: 'bundle',
+ define: {
+ 'process.env.CLI_VERSION': JSON.stringify(pkg.version),
+ // Make global available for compatibility
+ global: 'globalThis',
+ },
+ loader: { '.node': 'file' },
+ plugins: [wasmBinaryPlugin, wasmLoader({ mode: 'embedded' })],
+ write: true,
+ keepNames: true,
+};
+
+const mainBuild = esbuild.build({
+ ...commonBundleOptions,
+ entryPoints: ['packages/cli/index.ts'],
+ outfile: 'dist/cli.js',
+ inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')],
+ banner: {
+ js: `// Force strict mode and setup for ESM
"use strict";`,
- },
- alias: {
- 'is-in-ci': path.resolve(
- __dirname,
- 'packages/cli/src/patches/is-in-ci.ts',
- ),
- '@qwen-code/web-templates': path.resolve(
- __dirname,
- 'packages/web-templates/src/index.ts',
- ),
- // Resolve to userland punycode instead of deprecated node:punycode built-in
- punycode: require.resolve('punycode/'),
- },
- define: {
- 'process.env.CLI_VERSION': JSON.stringify(pkg.version),
- // Make global available for compatibility
- global: 'globalThis',
- },
- loader: { '.node': 'file' },
- plugins: [wasmBinaryPlugin, wasmLoader({ mode: 'embedded' })],
- metafile: true,
- write: true,
- keepNames: true,
- })
- .then(({ metafile }) => {
+ },
+ alias: {
+ 'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
+ '@qwen-code/web-templates': path.resolve(
+ __dirname,
+ 'packages/web-templates/src/index.ts',
+ ),
+ // Resolve to userland punycode instead of deprecated node:punycode built-in
+ punycode: require.resolve('punycode/'),
+ },
+ metafile: true,
+});
+
+// The file-index worker runs in its own worker_threads process and must exist
+// as a standalone file next to dist/cli.js so that `new URL('./fileIndexWorker.js',
+// import.meta.url)` resolves at runtime (the main bundle's import.meta.url is
+// dist/cli.js). We bundle it self-contained so fzf/fdir get inlined and no
+// node_modules resolution is required from the published tarball.
+const workerBuild = esbuild.build({
+ ...commonBundleOptions,
+ entryPoints: ['packages/core/src/utils/filesearch/fileIndexWorker.ts'],
+ outfile: 'dist/fileIndexWorker.js',
+ // fdir and other transitive CJS deps use require() at runtime, which is
+ // not available in ESM output without this shim. Same pattern as the main
+ // CLI bundle above.
+ inject: [path.resolve(__dirname, 'scripts/esbuild-shims.js')],
+ banner: {
+ js: `"use strict";`,
+ },
+});
+
+Promise.all([mainBuild, workerBuild])
+ .then(([{ metafile }]) => {
if (process.env.DEV === 'true') {
writeFileSync('./dist/esbuild.json', JSON.stringify(metafile, null, 2));
}
diff --git a/package-lock.json b/package-lock.json
index 9c18517c6e8..bd2d4520cbc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -236,7 +236,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -712,7 +711,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -736,7 +734,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -2178,7 +2175,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
- "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3601,7 +3597,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4073,7 +4068,6 @@
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4084,7 +4078,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4290,7 +4283,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -4536,7 +4528,6 @@
"integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/user-event": "^14.6.1",
@@ -4687,7 +4678,6 @@
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
@@ -4861,7 +4851,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5276,7 +5265,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/array-includes": {
"version": "3.1.9",
@@ -5824,7 +5814,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -6485,6 +6474,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
@@ -7562,7 +7552,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8267,6 +8256,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -8328,6 +8318,7 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -8337,6 +8328,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8346,6 +8338,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -8553,6 +8546,7 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
@@ -8571,6 +8565,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "2.0.0"
}
@@ -8579,13 +8574,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -9642,7 +9639,6 @@
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0",
"ansi-escapes": "^7.0.0",
@@ -10620,7 +10616,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -11500,6 +11495,7 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -12682,7 +12678,8 @@
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/path-type": {
"version": "3.0.0",
@@ -12845,6 +12842,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -12879,7 +12877,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -13039,7 +13036,6 @@
"integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13355,7 +13351,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13366,7 +13361,6 @@
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -13444,7 +13438,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14628,7 +14621,6 @@
"integrity": "sha512-fIQnFtpksRRgHR1CO1onGX3djaog4qsW/c5U8arqYTkUEr2TaWpn05mIJDOBoPJFlOdqFrB4Ttv0PZJxV7avhw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/icons": "^2.0.1",
@@ -15317,7 +15309,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -15517,8 +15508,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "peer": true
+ "license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -15526,7 +15516,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -15685,7 +15674,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16009,6 +15997,7 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 0.4.0"
}
@@ -16051,7 +16040,6 @@
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
@@ -16165,7 +16153,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -16179,7 +16166,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -16698,7 +16684,6 @@
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
- "peer": true,
"bin": {
"yaml": "bin.mjs"
},
@@ -16869,7 +16854,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17040,7 +17024,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@hono/node-server": "^1.19.7",
"ajv": "^8.17.1",
@@ -17699,7 +17682,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@hono/node-server": "^1.19.7",
"ajv": "^8.17.1",
@@ -18094,7 +18076,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -18857,7 +18838,6 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -19338,7 +19318,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -20465,7 +20444,6 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1",
@@ -21702,7 +21680,6 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -22676,7 +22653,6 @@
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -22691,7 +22667,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index bd5a4a3ddb4..3ad65ce5190 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -56,8 +56,10 @@ import {
type PermissionMode,
ToolConfirmationOutcome,
type WaitingToolCall,
+ FileIndexService,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
+import { buildFileSearchOptions } from './hooks/useAtCompletion.js';
import { validateAuthMethod } from '../config/auth.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import process from 'node:process';
@@ -313,6 +315,26 @@ export const AppContainer = (props: AppContainerProps) => {
await config.initialize();
setConfigInitialized(true);
+ // Pre-warm the file index so the first `@` keypress usually finds a
+ // ready-or-nearly-ready snapshot instead of kicking off a cold crawl.
+ // The options shape must hash identically to useAtCompletion's for
+ // both sites to hit the same FileIndexService singleton — we share
+ // `buildFileSearchOptions` between them so they can't drift.
+ // Skip the prewarm entirely when recursive file search is disabled;
+ // otherwise users who opted out still pay for a full crawl on startup
+ // and the worker they never use sticks around.
+ // Fire-and-forget: errors surface via the normal search path the next
+ // time the hook is used.
+ if (config.getEnableRecursiveFileSearch() !== false) {
+ try {
+ FileIndexService.for(
+ buildFileSearchOptions(config, config.getTargetDir()),
+ );
+ } catch {
+ // ignore — the hook will spawn on demand if pre-warm throws.
+ }
+ }
+
const resumedSessionData = config.getResumedSessionData();
if (resumedSessionData) {
const historyItems = buildResumedHistoryItems(
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 263988686c3..c5bda6bea92 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -16,7 +16,6 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
-import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { FeedbackDialog } from '../FeedbackDialog.js';
import { t } from '../../i18n/index.js';
@@ -104,8 +103,6 @@ export const Composer = () => {
/>
)}
- {!uiState.isConfigInitialized && }
-
{uiState.isFeedbackDialogOpen && }
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
index e2162924bb0..540467ad5e4 100644
--- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
@@ -6,7 +6,16 @@
/** @vitest-environment jsdom */
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useAtCompletion } from './useAtCompletion.js';
import type {
@@ -15,9 +24,11 @@ import type {
FileSystemStructure,
} from '@qwen-code/qwen-code-core';
import {
+ FileIndexService,
FileSearchFactory,
createTmpDir,
cleanupTmpDir,
+ installInProcessIndexTransport,
} from '@qwen-code/qwen-code-core';
import { useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
@@ -45,6 +56,22 @@ function useTestHarnessForAtCompletion(
}
describe('useAtCompletion', () => {
+ // The default FileIndexService transport spawns a worker_thread against
+ // the compiled `fileIndexWorker.js`; under vitest that URL resolves to
+ // a TS source the worker can't parse. Opt in to the in-process backend
+ // for this file only — doing so at test-setup level would pull core's
+ // module tree (including `workspaceContext.ts` with real `node:fs`)
+ // into every other test file's graph and clobber their `vi.mock('fs')`
+ // declarations.
+ let restoreTransport: (() => void) | null = null;
+ beforeAll(() => {
+ restoreTransport = installInProcessIndexTransport();
+ });
+ afterAll(() => {
+ restoreTransport?.();
+ restoreTransport = null;
+ });
+
let testRootDir: string;
let mockConfig: Config;
@@ -61,6 +88,12 @@ describe('useAtCompletion', () => {
});
afterEach(async () => {
+ // Dispose any live FileIndexService singletons before removing the
+ // temp dir. On Windows, an in-flight ripgrep child launched with
+ // `cwd: testRootDir` keeps a handle on the directory until it exits;
+ // rmdir'ing while that handle is open returns EBUSY. Resetting the
+ // service tears down the transport (and its rg subprocess) first.
+ await FileIndexService.__resetForTests();
if (testRootDir) {
await cleanupTmpDir(testRootDir);
}
@@ -116,12 +149,20 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
- expect(result.current.suggestions.map((s) => s.value)).toEqual([
- 'src/',
- 'src/components/',
- 'src/index.js',
- 'src/components/Button.tsx',
- ]);
+ // fzf ranks matches by score and breaks ties using list position,
+ // which depends on the crawler's emission order. That order is a
+ // ripgrep-vs-fdir implementation detail; asserting on the exact
+ // ranking was coupling this test to fdir's legacy breadth-first
+ // output. Content is what matters — all `src/` entries should be
+ // returned, nothing else.
+ expect(result.current.suggestions.map((s) => s.value).sort()).toEqual(
+ [
+ 'src/',
+ 'src/components/',
+ 'src/components/Button.tsx',
+ 'src/index.js',
+ ].sort(),
+ );
});
it('should append a trailing slash to directory paths in suggestions', async () => {
@@ -139,24 +180,23 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
- expect(result.current.suggestions.map((s) => s.value)).toEqual([
- 'dir/',
- 'file.txt',
- ]);
+ expect(result.current.suggestions.map((s) => s.value).sort()).toEqual(
+ ['dir/', 'file.txt'].sort(),
+ );
});
});
describe('UI State and Loading Behavior', () => {
- it('should be in a loading state during initial file system crawl', async () => {
+ it('settles into non-loading state after initial file system crawl', async () => {
testRootDir = await createTmpDir({});
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
);
- // It's initially true because the effect runs synchronously.
- expect(result.current.isLoadingSuggestions).toBe(true);
-
- // Wait for the loading to complete.
+ // The transient INITIALIZING → READY window no longer flashes the
+ // loading indicator synchronously; `isLoading` only flips to true if
+ // initialization stays slow past the 200 ms threshold. Either way, the
+ // steady state after a fast crawl is `false`.
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
@@ -396,10 +436,9 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
- expect(result.current.suggestions.map((s) => s.value)).toEqual([
- 'src/',
- '.gitignore',
- ]);
+ expect(result.current.suggestions.map((s) => s.value).sort()).toEqual(
+ ['src/', '.gitignore'].sort(),
+ );
});
it('should work correctly when config is undefined', async () => {
@@ -417,10 +456,9 @@ describe('useAtCompletion', () => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
- expect(result.current.suggestions.map((s) => s.value)).toEqual([
- 'node_modules/',
- 'src/',
- ]);
+ expect(result.current.suggestions.map((s) => s.value).sort()).toEqual(
+ ['node_modules/', 'src/'].sort(),
+ );
});
it('should reset and re-initialize when the cwd changes', async () => {
@@ -452,13 +490,9 @@ describe('useAtCompletion', () => {
rerender({ cwd: rootDir2, pattern: 'file' });
});
- // After CWD changes, suggestions should be cleared and it should load again.
- await waitFor(() => {
- expect(result.current.isLoadingSuggestions).toBe(true);
- expect(result.current.suggestions).toEqual([]);
- });
-
- // Wait for the new suggestions from the second directory
+ // After CWD changes, the RESET clears suggestions; the loading flash
+ // is no longer synchronous (suppressed for <200 ms inits), so we only
+ // assert the end state.
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'file2.txt',
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts
index 8f3c870ba6b..e98d6502821 100644
--- a/packages/cli/src/ui/hooks/useAtCompletion.ts
+++ b/packages/cli/src/ui/hooks/useAtCompletion.ts
@@ -4,12 +4,52 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useEffect, useReducer, useRef } from 'react';
-import type { Config, FileSearch } from '@qwen-code/qwen-code-core';
-import { FileSearchFactory, escapePath } from '@qwen-code/qwen-code-core';
+import { useEffect, useMemo, useReducer, useRef } from 'react';
+import type {
+ Config,
+ FileSearch,
+ FileSearchOptions,
+} from '@qwen-code/qwen-code-core';
+import {
+ FileIndexService,
+ FileSearchFactory,
+ escapePath,
+} from '@qwen-code/qwen-code-core';
+
+/**
+ * Builds the `FileSearchOptions` object used to key the `FileIndexService`
+ * singleton. Shared between `useAtCompletion` (the hot search path) and
+ * `AppContainer` (the startup pre-warm). Both sites MUST produce identical
+ * option shapes — the key is a sha256 of the JSON of these fields, so a
+ * field mismatch silently spawns a second worker that never gets a hit.
+ * Keeping the derivation in one place is the guardrail against that drift.
+ */
+export function buildFileSearchOptions(
+ config: Config | undefined,
+ projectRoot: string,
+): FileSearchOptions {
+ return {
+ projectRoot,
+ ignoreDirs: [],
+ useGitignore: config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
+ useQwenignore: config?.getFileFilteringOptions()?.respectQwenIgnore ?? true,
+ cache: true,
+ cacheTtl: 30,
+ enableRecursiveFileSearch: config?.getEnableRecursiveFileSearch() ?? true,
+ // `!== false` defaults to true when the getter returns undefined.
+ enableFuzzySearch: config?.getFileFilteringEnableFuzzySearch() !== false,
+ };
+}
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
+/**
+ * Delay before replaying the current query against an updated partial
+ * snapshot. Keeps us from burning work when fdir bursts hundreds of chunks
+ * per second, but short enough that results feel live.
+ */
+const PARTIAL_REFRESH_THROTTLE_MS = 80;
+
export enum AtCompletionStatus {
IDLE = 'idle',
INITIALIZING = 'initializing',
@@ -23,12 +63,17 @@ interface AtCompletionState {
suggestions: Suggestion[];
isLoading: boolean;
pattern: string | null;
+ // Monotonic counter bumped on every REFRESH so effects depending on state
+ // can re-run even when `status` and `pattern` stay the same (e.g. REFRESH
+ // hits while we are already in SEARCHING).
+ refreshToken: number;
}
type AtCompletionAction =
| { type: 'INITIALIZE' }
| { type: 'INITIALIZE_SUCCESS' }
| { type: 'SEARCH'; payload: string }
+ | { type: 'REFRESH' }
| { type: 'SEARCH_SUCCESS'; payload: Suggestion[] }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'ERROR' }
@@ -39,6 +84,7 @@ const initialState: AtCompletionState = {
suggestions: [],
isLoading: false,
pattern: null,
+ refreshToken: 0,
};
function atCompletionReducer(
@@ -47,13 +93,14 @@ function atCompletionReducer(
): AtCompletionState {
switch (action.type) {
case 'INITIALIZE':
- return {
- ...state,
- status: AtCompletionStatus.INITIALIZING,
- isLoading: true,
- };
+ // Don't flip isLoading here. The Worker effect arms a 200ms timer via
+ // SET_LOADING so the "Loading suggestions..." placeholder only appears
+ // when initialization is actually slow. For the common case — worker
+ // already pre-warmed, first search resolves in <200ms — the picker
+ // opens silently and fills in results without any loading flash.
+ return { ...state, status: AtCompletionStatus.INITIALIZING };
case 'INITIALIZE_SUCCESS':
- return { ...state, status: AtCompletionStatus.READY, isLoading: false };
+ return { ...state, status: AtCompletionStatus.READY };
case 'SEARCH':
// Keep old suggestions, don't set loading immediately
return {
@@ -61,6 +108,25 @@ function atCompletionReducer(
status: AtCompletionStatus.SEARCHING,
pattern: action.payload,
};
+ case 'REFRESH':
+ // Re-run the current pattern against a newly-grown snapshot. Only
+ // meaningful when a pattern is active and we've finished the initial
+ // load. Preserves pattern and isLoading. Bumps `refreshToken` so the
+ // Worker effect observes a dep change even when status was already
+ // SEARCHING (common: partial arrives while the first search is still
+ // in flight, and without this bump the effect would not re-run).
+ if (
+ state.pattern === null ||
+ (state.status !== AtCompletionStatus.READY &&
+ state.status !== AtCompletionStatus.SEARCHING)
+ ) {
+ return state;
+ }
+ return {
+ ...state,
+ status: AtCompletionStatus.SEARCHING,
+ refreshToken: state.refreshToken + 1,
+ };
case 'SEARCH_SUCCESS':
return {
...state,
@@ -69,8 +135,15 @@ function atCompletionReducer(
isLoading: false,
};
case 'SET_LOADING':
- // Only show loading if we are still in a searching state
- if (state.status === AtCompletionStatus.SEARCHING) {
+ // Only show loading if we are still working (initial crawl or an
+ // in-flight search). Covering INITIALIZING lets the 200ms threshold
+ // protect the initialization path too, so a genuinely slow cold start
+ // still surfaces a spinner after the threshold rather than appearing
+ // frozen.
+ if (
+ state.status === AtCompletionStatus.SEARCHING ||
+ state.status === AtCompletionStatus.INITIALIZING
+ ) {
return { ...state, isLoading: action.payload, suggestions: [] };
}
return state;
@@ -151,32 +224,70 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
}
}, [enabled, pattern, state.status, state.pattern]);
+ // Stable snapshot of the FileSearch options derived from config. The worker
+ // effect and the partial-subscription effect below both depend on this;
+ // memoising on `[config, cwd]` keeps the object reference stable across
+ // renders so it can safely go in effect dependency arrays.
+ const fileSearchOptions = useMemo(
+ () => buildFileSearchOptions(config, cwd),
+ [config, cwd],
+ );
+
+ // While the FileIndexService is still crawling, every new chunk expands the
+ // searchable snapshot. Subscribing here lets us replay the active pattern
+ // against the growing list so the user sees results progressively — similar
+ // to Claude Code's behaviour — rather than waiting for the full crawl. The
+ // subscription is bound to the project identity (cwd+config) rather than
+ // the reducer status so that chunks arriving mid-search still drive a
+ // REFRESH once the initial SEARCHING state completes.
+ useEffect(() => {
+ if (!fileSearchOptions.enableRecursiveFileSearch) return;
+
+ const service = FileIndexService.for(fileSearchOptions);
+ if (service.state === 'ready') return; // Nothing will stream anymore.
+
+ let refreshTimer: ReturnType | null = null;
+ const unsubscribe = service.onPartial(() => {
+ if (refreshTimer) clearTimeout(refreshTimer);
+ refreshTimer = setTimeout(() => {
+ dispatch({ type: 'REFRESH' });
+ }, PARTIAL_REFRESH_THROTTLE_MS);
+ });
+ return () => {
+ if (refreshTimer) clearTimeout(refreshTimer);
+ unsubscribe();
+ };
+ }, [fileSearchOptions]);
+
// The "Worker" that performs async operations based on status.
useEffect(() => {
const initialize = async () => {
+ // Arm the slow-load indicator for initialization too. In the normal
+ // pre-warmed path this timer never fires (crawl completes instantly)
+ // and the picker opens silently. On a cold start with a large tree
+ // the user sees the spinner after 200ms instead of wondering if @
+ // is broken.
+ if (slowSearchTimer.current) {
+ clearTimeout(slowSearchTimer.current);
+ }
+ slowSearchTimer.current = setTimeout(() => {
+ dispatch({ type: 'SET_LOADING', payload: true });
+ }, 200);
try {
- const searcher = FileSearchFactory.create({
- projectRoot: cwd,
- ignoreDirs: [],
- useGitignore:
- config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
- useQwenignore:
- config?.getFileFilteringOptions()?.respectQwenIgnore ?? true,
- cache: true,
- cacheTtl: 30, // 30 seconds
- enableRecursiveFileSearch:
- config?.getEnableRecursiveFileSearch() ?? true,
- // Use enableFuzzySearch with !== false to default to true when undefined.
- enableFuzzySearch:
- config?.getFileFilteringEnableFuzzySearch() !== false,
- });
+ const searcher = FileSearchFactory.create(fileSearchOptions);
await searcher.initialize();
+ if (slowSearchTimer.current) {
+ clearTimeout(slowSearchTimer.current);
+ }
fileSearch.current = searcher;
dispatch({ type: 'INITIALIZE_SUCCESS' });
if (state.pattern !== null) {
dispatch({ type: 'SEARCH', payload: state.pattern });
}
} catch (_) {
+ if (slowSearchTimer.current) {
+ clearTimeout(slowSearchTimer.current);
+ }
dispatch({ type: 'ERROR' });
}
};
@@ -235,5 +346,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
clearTimeout(slowSearchTimer.current);
}
};
- }, [state.status, state.pattern, config, cwd]);
+ // `state.refreshToken` is included so REFRESH re-triggers a search
+ // even when `state.status` was already SEARCHING from a previous call.
+ }, [state.status, state.pattern, state.refreshToken, fileSearchOptions]);
}
diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts
index c26e57fa5e9..97ffbdf92d3 100644
--- a/packages/cli/test-setup.ts
+++ b/packages/cli/test-setup.ts
@@ -16,3 +16,12 @@ if (process.env['QWEN_DEBUG_LOG_FILE'] === undefined) {
}
import './src/test-utils/customMatchers.js';
+
+// Note on FileIndexService: tests that exercise the worker-backed file
+// search must opt in to the in-process transport via a local `beforeAll`
+// (see e.g. `src/ui/hooks/useAtCompletion.test.ts`). Installing it here
+// would eagerly evaluate `@qwen-code/qwen-code-core`'s module tree —
+// including `workspaceContext.ts` with a real `node:fs` binding — before
+// any `vi.mock('fs', …)` in an individual test file can take effect,
+// breaking tests that rely on those mocks (e.g. `config.test.ts` bare
+// mode). Keep this file free of eager core imports.
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index e672e4adfd0..69dd5baf728 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -265,6 +265,10 @@ export * from './utils/errorParsing.js';
export * from './utils/errors.js';
export * from './utils/fileUtils.js';
export * from './utils/filesearch/fileSearch.js';
+export {
+ FileIndexService,
+ installInProcessIndexTransport,
+} from './utils/filesearch/fileIndexService.js';
export * from './utils/formatters.js';
export * from './utils/generateContentResponseUtilities.js';
export * from './utils/getFolderStructure.js';
diff --git a/packages/core/src/test-utils/file-system-test-helpers.ts b/packages/core/src/test-utils/file-system-test-helpers.ts
index 0824211b207..b5c5ce0c03b 100644
--- a/packages/core/src/test-utils/file-system-test-helpers.ts
+++ b/packages/core/src/test-utils/file-system-test-helpers.ts
@@ -91,8 +91,21 @@ export async function createTmpDir(
/**
* Cleans up (deletes) a temporary directory and its contents.
+ *
+ * On Windows, a freshly-terminated child process (e.g. ripgrep invoked with
+ * `cwd: dir`) can hold a handle on the directory for a few milliseconds
+ * after exit, and `fs.rm` then fails with `EBUSY`. Node's built-in
+ * `maxRetries`/`retryDelay` options absorb that race: 5 retries at 100ms
+ * gives the OS half a second to release the handle, which is far longer
+ * than the typical tens-of-ms window we've observed in CI.
+ *
* @param dir The absolute path to the temporary directory to clean up.
*/
export async function cleanupTmpDir(dir: string) {
- await fs.rm(dir, { recursive: true, force: true });
+ await fs.rm(dir, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 100,
+ });
}
diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts
index 851336941f5..14a88686faf 100644
--- a/packages/core/src/utils/editor.test.ts
+++ b/packages/core/src/utils/editor.test.ts
@@ -462,7 +462,9 @@ describe('editor utils', () => {
throw new Error(); // CLI not found
});
// Accept any path containing Zed.app
- (existsSync as Mock).mockImplementation((path: string) => path.includes('Zed.app'));
+ (existsSync as Mock).mockImplementation((path: string) =>
+ path.includes('Zed.app'),
+ );
const mockSpawnOn = vi.fn((event, cb) => {
if (event === 'close') {
@@ -716,7 +718,9 @@ describe('editor utils', () => {
throw new Error(); // CLI not found
});
// Accept any path containing Zed.app (the CLI check will be for Contents/MacOS/cli)
- (existsSync as Mock).mockImplementation((path: string) => path.includes('Zed.app'));
+ (existsSync as Mock).mockImplementation((path: string) =>
+ path.includes('Zed.app'),
+ );
const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).not.toBeNull();
@@ -759,7 +763,9 @@ describe('editor utils', () => {
throw new Error(); // CLI not found
});
// Accept any path containing Zed.app
- (existsSync as Mock).mockImplementation((path: string) => path.includes('Zed.app'));
+ (existsSync as Mock).mockImplementation((path: string) =>
+ path.includes('Zed.app'),
+ );
const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).not.toBeNull();
diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts
index d5d22623ad5..c199631691a 100644
--- a/packages/core/src/utils/editor.ts
+++ b/packages/core/src/utils/editor.ts
@@ -125,7 +125,7 @@ export function getEditorExecutable(editorType: EditorType): string | null {
return found;
}
- // Special handling for Zed on macOS: check app bundle CLI as fallback
+ // Special handling for Zed on macOS: check app bundle CLI as fallback
if (editorType === 'zed' && process.platform === 'darwin') {
for (const appPath of getZedAppPaths()) {
const cliPath = join(appPath, 'Contents/MacOS/cli');
diff --git a/packages/core/src/utils/filesearch/crawlCache.ts b/packages/core/src/utils/filesearch/crawlCache.ts
index 66a7e3d4c1b..6df04fe40f9 100644
--- a/packages/core/src/utils/filesearch/crawlCache.ts
+++ b/packages/core/src/utils/filesearch/crawlCache.ts
@@ -50,11 +50,16 @@ export const write = (key: string, results: string[], ttlMs: number): void => {
// Store the new data
crawlCache.set(key, results);
- // Set a timer to automatically delete the cache entry after the TTL
+ // Set a timer to automatically delete the cache entry after the TTL.
+ // `.unref()` so a pending TTL (up to 30s by default) doesn't keep the
+ // event loop alive at process exit — otherwise vitest workers hang
+ // until the timer fires. `clear()` still drops them synchronously
+ // between tests.
const timerId = setTimeout(() => {
crawlCache.delete(key);
cacheTimers.delete(key);
}, ttlMs);
+ timerId.unref?.();
// Store the timer handle so we can clear it if the entry is updated
cacheTimers.set(key, timerId);
diff --git a/packages/core/src/utils/filesearch/crawler.ts b/packages/core/src/utils/filesearch/crawler.ts
index 0fdf282b335..f457b826e2f 100644
--- a/packages/core/src/utils/filesearch/crawler.ts
+++ b/packages/core/src/utils/filesearch/crawler.ts
@@ -8,6 +8,7 @@ import path from 'node:path';
import { fdir } from 'fdir';
import type { Ignore } from './ignore.js';
import * as cache from './crawlCache.js';
+import { buildRipgrepFileFilter, ripgrepCrawl } from './ripgrepCrawler.js';
export interface CrawlOptions {
// The directory to start the crawl from.
@@ -23,31 +24,74 @@ export interface CrawlOptions {
// Caching options.
cache: boolean;
cacheTtl: number;
+ // Optional streaming callback. If provided, is invoked with batches of
+ // cwd-relative paths as the underlying walker discovers them. A final
+ // batch containing any remainder is flushed just before the crawl
+ // resolves. Errors thrown by the callback are caught and ignored.
+ onProgress?: (chunk: string[]) => void;
+ // Buffer flushing thresholds for onProgress. Flush when either hits first.
+ progressChunkSize?: number; // default 2000
+ progressFlushMs?: number; // default 50
+ // Abort signal threaded through to ripgrep so a search change can kill
+ // an in-flight crawl early.
+ signal?: AbortSignal;
+ // Escape hatch: force the fdir backend even when ripgrep would be
+ // eligible. Mostly for tests; in production callers always default to
+ // the faster ripgrep path.
+ preferFdir?: boolean;
}
function toPosixPath(p: string) {
return p.split(path.sep).join(path.posix.sep);
}
-export async function crawl(options: CrawlOptions): Promise {
- if (options.cache) {
- const cacheKey = cache.getCacheKey(
- options.crawlDirectory,
- options.ignore.getFingerprint(),
- options.maxDepth,
- options.maxFiles,
- );
- const cachedResults = cache.read(cacheKey);
+/**
+ * Timestamp (ms) at which the ripgrep fast path was last disabled, or `0`
+ * if rg is currently eligible. A single spawn failure (missing binary,
+ * sandbox race, transient resource exhaustion) shouldn't downgrade the
+ * process forever — long-lived hosts like the VSCode extension would pay
+ * the fdir penalty for the rest of the session. We cool down for
+ * `RIPGREP_DISABLED_COOLDOWN_MS` and then re-try on the next crawl.
+ */
+const RIPGREP_DISABLED_COOLDOWN_MS = 5 * 60 * 1000;
+let ripgrepDisabledAt = 0;
- if (cachedResults) {
- return cachedResults;
- }
+function isRipgrepDisabled(): boolean {
+ if (ripgrepDisabledAt === 0) return false;
+ if (Date.now() - ripgrepDisabledAt >= RIPGREP_DISABLED_COOLDOWN_MS) {
+ ripgrepDisabledAt = 0;
+ return false;
}
+ return true;
+}
+
+/** For tests: let a suite re-enable the ripgrep fast path after forcing failures. */
+export function __resetRipgrepDisabledForTests(): void {
+ ripgrepDisabledAt = 0;
+}
+async function fdirCrawl(options: CrawlOptions): Promise {
const posixCwd = toPosixPath(options.cwd);
const posixCrawlDirectory = toPosixPath(options.crawlDirectory);
const relativeToCrawlDir = path.posix.relative(posixCwd, posixCrawlDirectory);
+ const onProgress = options.onProgress;
+ const chunkSize = options.progressChunkSize ?? 2000;
+ const flushMs = options.progressFlushMs ?? 50;
+ let progressBuffer: string[] = [];
+ let lastFlushAt = Date.now();
+ const flushProgress = () => {
+ if (!onProgress || progressBuffer.length === 0) return;
+ const toSend = progressBuffer;
+ progressBuffer = [];
+ lastFlushAt = Date.now();
+ try {
+ onProgress(toSend);
+ } catch {
+ // swallow; the caller is best-effort
+ }
+ };
+
let results: string[];
try {
const dirFilter = options.ignore.getDirectoryFilter();
@@ -61,12 +105,23 @@ export async function crawl(options: CrawlOptions): Promise {
return dirFilter(`${relativePath}/`);
})
.filter((filePath, isDirectory) => {
- // Directories are already handled by the exclude() callback above.
- if (isDirectory) return true;
// Apply file-level ignore patterns (e.g. *.log, *.map) during the
- // crawl so they don't consume the maxFiles budget.
+ // crawl so they don't consume the maxFiles budget. Directories are
+ // already handled by the exclude() callback above, but we still buffer
+ // them for the onProgress stream so partial snapshots include
+ // directory entries in their natural position.
const cwdRelative = path.posix.join(relativeToCrawlDir, filePath);
- return !fileFilter(cwdRelative);
+ const keep = isDirectory ? true : !fileFilter(cwdRelative);
+ if (keep && onProgress) {
+ progressBuffer.push(cwdRelative);
+ if (
+ progressBuffer.length >= chunkSize ||
+ Date.now() - lastFlushAt >= flushMs
+ ) {
+ flushProgress();
+ }
+ }
+ return keep;
});
if (options.maxDepth !== undefined) {
@@ -80,12 +135,126 @@ export async function crawl(options: CrawlOptions): Promise {
results = await api.crawl(options.crawlDirectory).withPromise();
} catch (_e) {
// The directory probably doesn't exist.
+ flushProgress();
return [];
}
- const relativeToCwdResults = results.map((p) =>
- path.posix.join(relativeToCrawlDir, p),
- );
+ flushProgress();
+
+ return results.map((p) => path.posix.join(relativeToCrawlDir, p));
+}
+
+/**
+ * Directory-only hints we hand to rg as `--glob '!dir'` args so its walker
+ * can skip the subtree entirely instead of streaming every path under it
+ * for the Node post-filter to reject. rg already understands `.gitignore`
+ * / `.ignore` natively, so those rules don't need forwarding; the
+ * additions here are:
+ *
+ * - user-supplied `ignoreDirs` (from the `FileSearchOptions` contract),
+ * - directory-style patterns from `.qwenignore` (which rg doesn't read),
+ * extracted from the shared Ignore's fingerprint.
+ *
+ * The post-filter in `ripgrepCrawler` is still the source of truth — this
+ * is a speed optimisation only. Patterns that contain glob metacharacters
+ * (`*`, `?`, `[`, `!`, `/`) or newlines are skipped: rg would interpret
+ * them, and getting the semantics wrong risks silently hiding files.
+ * Plain directory names pass through unchanged.
+ */
+function collectRipgrepExcludeDirs(options: CrawlOptions): string[] {
+ const out: string[] = [];
+ const seen = new Set();
+ const push = (raw: string) => {
+ const p = raw.replace(/^\/+|\/+$/g, '');
+ if (!p || /[*?[\]]/.test(p) || p.includes('/') || p.includes('\n')) {
+ return;
+ }
+ if (seen.has(p)) return;
+ seen.add(p);
+ out.push(p);
+ };
+ // Pull plain directory patterns (e.g. `build/`, `dist/`, `.git/`, or
+ // anything the caller passed as `ignoreDirs` which `loadIgnoreRules`
+ // normalised into a trailing-slash pattern) out of the ignore
+ // fingerprint. gitignore-family syntax is line-oriented; we only accept
+ // patterns that are unambiguously a bare directory name so we don't
+ // confuse rg with `foo/**` or `!foo/`. The post-filter in ripgrepCrawler
+ // is still the source of truth — this is a speed optimisation only so rg
+ // can prune whole subtrees at its walker rather than streaming every
+ // path under them for the Node filter to discard.
+ const fingerprint = options.ignore.getFingerprint?.() ?? '';
+ for (const line of fingerprint.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!'))
+ continue;
+ if (!trimmed.endsWith('/')) continue;
+ push(trimmed);
+ }
+ return out;
+}
+
+export async function crawl(options: CrawlOptions): Promise {
+ if (options.cache) {
+ const cacheKey = cache.getCacheKey(
+ options.crawlDirectory,
+ options.ignore.getFingerprint(),
+ options.maxDepth,
+ options.maxFiles,
+ );
+ const cachedResults = cache.read(cacheKey);
+
+ if (cachedResults) {
+ return cachedResults;
+ }
+ }
+
+ // Benchmark findings (measured against fdir on the same tree):
+ //
+ // qwen-code repo (~2700 files) fdir ~25ms rg ~140ms fdir wins
+ // project/node_modules (~48k) fdir ~640ms rg ~1800ms fdir wins
+ // ~/ home dir (100k-file cap) fdir ~9s rg ~2.5s rg 3-4× wins
+ //
+ // On small repos Node's `spawn`+stdout IPC overhead (~50-100ms baseline)
+ // beats rg's native parallel walker. Past roughly 50k files the picture
+ // flips: fdir's single-threaded JS walk plus per-entry `.gitignore`
+ // callbacks into the `ignore` package balloon, while rg's Rust walker
+ // stays saturated across cores and keeps output flowing. Since the slow
+ // case is the painful one (a user typing @ at $HOME shouldn't wait 9s)
+ // and the fast case is already well under the 200 ms loading threshold
+ // regardless of backend, we default to rg and let callers force fdir
+ // via `QWEN_FILESEARCH_USE_RG=0` if needed.
+ const rgEnvVar = process.env['QWEN_FILESEARCH_USE_RG'];
+ const ripgrepEnabled = rgEnvVar === undefined ? true : rgEnvVar !== '0';
+ const canUseRipgrep =
+ ripgrepEnabled &&
+ !options.preferFdir &&
+ !isRipgrepDisabled() &&
+ options.maxDepth === undefined;
+
+ let results: string[] | undefined;
+ if (canUseRipgrep) {
+ try {
+ const ripResult = await ripgrepCrawl({
+ crawlDirectory: options.crawlDirectory,
+ cwd: options.cwd,
+ maxFiles: options.maxFiles,
+ extraExcludeDirs: collectRipgrepExcludeDirs(options),
+ fileFilter: buildRipgrepFileFilter(options.ignore),
+ onProgress: options.onProgress,
+ progressChunkSize: options.progressChunkSize,
+ progressFlushMs: options.progressFlushMs,
+ signal: options.signal,
+ });
+ results = ripResult.files;
+ } catch (_e) {
+ ripgrepDisabledAt = Date.now();
+ results = undefined;
+ }
+ }
+
+ if (results === undefined) {
+ results = await fdirCrawl(options);
+ }
if (options.cache) {
const cacheKey = cache.getCacheKey(
@@ -94,8 +263,8 @@ export async function crawl(options: CrawlOptions): Promise {
options.maxDepth,
options.maxFiles,
);
- cache.write(cacheKey, relativeToCwdResults, options.cacheTtl * 1000);
+ cache.write(cacheKey, results, options.cacheTtl * 1000);
}
- return relativeToCwdResults;
+ return results;
}
diff --git a/packages/core/src/utils/filesearch/fileIndexCore.test.ts b/packages/core/src/utils/filesearch/fileIndexCore.test.ts
new file mode 100644
index 00000000000..f6d19ff7600
--- /dev/null
+++ b/packages/core/src/utils/filesearch/fileIndexCore.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { afterEach, describe, expect, it } from 'vitest';
+import { FileIndexCore } from './fileIndexCore.js';
+import {
+ cleanupTmpDir,
+ createTmpDir,
+} from '../../test-utils/file-system-test-helpers.js';
+
+describe('FileIndexCore', () => {
+ let tmpDir: string;
+ afterEach(async () => {
+ if (tmpDir) await cleanupTmpDir(tmpDir);
+ });
+
+ const baseOptions = (projectRoot: string) => ({
+ projectRoot,
+ ignoreDirs: [] as string[],
+ useGitignore: false,
+ useQwenignore: false,
+ cache: false,
+ cacheTtl: 0,
+ enableRecursiveFileSearch: true,
+ enableFuzzySearch: true,
+ });
+
+ it('streams discovered files via onChunk before resolving', async () => {
+ const structure: Record = {};
+ for (let i = 0; i < 5; i++) structure[`file${i}.txt`] = '';
+ tmpDir = await createTmpDir(structure);
+
+ const core = new FileIndexCore(baseOptions(tmpDir));
+ const received: string[] = [];
+ await core.startCrawl((chunk) => {
+ for (const p of chunk) received.push(p);
+ });
+
+ // Every file should have been streamed out; the live snapshot should
+ // mirror that count.
+ expect(received.length).toBeGreaterThan(0);
+ expect(core.snapshotSize).toBe(received.length);
+ expect(core.isReady).toBe(true);
+ });
+
+ it('returns results from the partial snapshot before buildFzfIndex is called', async () => {
+ const structure: Record = {
+ 'apple.txt': '',
+ 'banana.txt': '',
+ 'cherry.txt': '',
+ };
+ tmpDir = await createTmpDir(structure);
+
+ const core = new FileIndexCore(baseOptions(tmpDir));
+ await core.startCrawl();
+ // Intentionally skip `buildFzfIndex()` — simulate the "still crawling"
+ // window. Search must still work, falling through to picomatch.
+ const results = await core.search('apple');
+ expect(results).toContain('apple.txt');
+ expect(results).not.toContain('banana.txt');
+ });
+
+ it('uses fzf after buildFzfIndex for fuzzy queries', async () => {
+ tmpDir = await createTmpDir({
+ src: {
+ 'LoadingIndicator.tsx': '',
+ 'Thumbnail.tsx': '',
+ },
+ });
+
+ const core = new FileIndexCore(baseOptions(tmpDir));
+ await core.startCrawl();
+ core.buildFzfIndex();
+
+ // 'LoInd' is a fuzzy subsequence of LoadingIndicator; picomatch would
+ // never find this, fzf will.
+ const results = await core.search('LoInd');
+ expect(results.some((p) => p.includes('LoadingIndicator'))).toBe(true);
+ });
+
+ it('returns empty results for malformed glob patterns', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '', 'b.txt': '' });
+ const core = new FileIndexCore(baseOptions(tmpDir));
+ await core.startCrawl();
+ core.buildFzfIndex();
+ // An unmatched `[` is a common interim state while the user is typing a
+ // character class; picomatch throws on compile. The core should absorb
+ // that and return an empty list instead of propagating the TypeError.
+ // Use a wildcard path so the glob branch (not fzf) handles it.
+ const results = await core.search('foo[*');
+ expect(results).toEqual([]);
+ });
+
+ it('respects maxResults during snapshot-phase searches', async () => {
+ const structure: Record = {};
+ for (let i = 0; i < 30; i++) structure[`match${i}.txt`] = '';
+ tmpDir = await createTmpDir(structure);
+
+ const core = new FileIndexCore(baseOptions(tmpDir));
+ await core.startCrawl();
+ const results = await core.search('match', { maxResults: 5 });
+ expect(results).toHaveLength(5);
+ });
+});
diff --git a/packages/core/src/utils/filesearch/fileIndexCore.ts b/packages/core/src/utils/filesearch/fileIndexCore.ts
new file mode 100644
index 00000000000..d902ae12e06
--- /dev/null
+++ b/packages/core/src/utils/filesearch/fileIndexCore.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { FzfResultItem } from 'fzf';
+import { AsyncFzf } from 'fzf';
+import { crawl } from './crawler.js';
+import type { Ignore } from './ignore.js';
+import { loadIgnoreRules } from './ignore.js';
+import { ResultCache } from './result-cache.js';
+import type { FileSearchOptions, SearchOptions } from './fileSearch.js';
+import { AbortError, filter } from './fileSearch.js';
+import { unescapePath } from '../paths.js';
+
+/**
+ * Safety cap on the number of file entries the recursive crawler will
+ * materialise in memory. Kept in sync with the previous constant in
+ * fileSearch.ts so behaviour is unchanged.
+ */
+export const MAX_CRAWL_FILES = 100_000;
+
+/**
+ * Pure, worker-safe core of the recursive file search engine. It owns the
+ * crawled file list, the fzf index, and the prefix-aware result cache. The
+ * main-thread `FileIndexService` drives this class indirectly through the
+ * worker; unit tests can instantiate it directly.
+ *
+ * Lifecycle:
+ * 1. `startCrawl(onChunk)` — kicks off the filesystem crawl. During the
+ * crawl the `allFiles` array grows and `onChunk` is invoked with batches
+ * of discovered paths. `search()` may be called concurrently; it will
+ * operate against the current snapshot with picomatch-based filtering.
+ * 2. `buildFzfIndex()` — invoked once after `startCrawl` resolves. Enables
+ * the fuzzy-matching fast path for subsequent `search()` calls.
+ * 3. `search(pattern, opts)` — can be called any time after the constructor.
+ * Before step 2 it falls back to substring/glob matching via `filter()`;
+ * after step 2 it uses fzf for non-glob patterns.
+ */
+export class FileIndexCore {
+ private readonly ignore: Ignore;
+ private allFiles: string[] = [];
+ private fzf: AsyncFzf | undefined;
+ private resultCache: ResultCache | undefined;
+ private crawlDone = false;
+
+ constructor(private readonly options: FileSearchOptions) {
+ this.ignore = loadIgnoreRules(options);
+ }
+
+ /**
+ * Runs the recursive crawl. Resolves once fdir finishes collecting all
+ * files. Before resolution, `onChunk` is invoked multiple times with slices
+ * of paths as they are discovered.
+ */
+ async startCrawl(onChunk?: (chunk: string[]) => void): Promise {
+ let streamed = false;
+ const full = await crawl({
+ crawlDirectory: this.options.projectRoot,
+ cwd: this.options.projectRoot,
+ ignore: this.ignore,
+ cache: this.options.cache,
+ cacheTtl: this.options.cacheTtl,
+ maxDepth: this.options.maxDepth,
+ maxFiles: MAX_CRAWL_FILES,
+ onProgress: (chunk) => {
+ // Append to the live snapshot first so concurrent `search()` calls
+ // see the growing list immediately.
+ for (const p of chunk) this.allFiles.push(p);
+ streamed = true;
+ try {
+ onChunk?.(chunk);
+ } catch {
+ // best-effort; don't break the crawl
+ }
+ },
+ });
+ // On cache hits (or small trees) `onProgress` never fires; fall back to
+ // the fulfilled crawl() return value. Push into the existing array rather
+ // than replacing the reference so any `search()` already iterating
+ // `this.allFiles` keeps observing a stable list.
+ if (!streamed) {
+ for (const p of full) this.allFiles.push(p);
+ }
+ this.crawlDone = true;
+ }
+
+ /**
+ * Builds the fzf fuzzy index over the current `allFiles`. Also freezes the
+ * ResultCache to `allFiles` so subsequent queries benefit from prefix
+ * chaining. Called exactly once, after `startCrawl` resolves.
+ */
+ buildFzfIndex(): void {
+ this.resultCache = new ResultCache(this.allFiles);
+ if (this.options.enableFuzzySearch !== false) {
+ // v1 is much faster than v2 on large search spaces; stick to the same
+ // >20k threshold that the previous implementation used.
+ this.fzf = new AsyncFzf(this.allFiles, {
+ fuzzy: this.allFiles.length > 20000 ? 'v1' : 'v2',
+ });
+ }
+ }
+
+ /**
+ * Runs a search against the current snapshot. When `buildFzfIndex()` has
+ * not yet been called, falls back to picomatch-based substring/glob
+ * filtering so partial results can stream to the UI while the index is
+ * still warming up.
+ */
+ async search(
+ pattern: string,
+ options: SearchOptions = {},
+ ): Promise {
+ const query = unescapePath(pattern) || '*';
+ const fileFilter = this.ignore.getFileFilter();
+
+ let filteredCandidates: string[];
+ if (!this.resultCache) {
+ // Snapshot / pre-index phase: no result cache yet (either crawl is
+ // still running or buildFzfIndex has not been invoked). picomatch-
+ // filter the live snapshot directly; skip caching so we never stash
+ // results that predate additional files.
+ filteredCandidates = await filter(this.allFiles, query, options.signal);
+ } else {
+ const { files: candidates, isExactMatch } =
+ await this.resultCache!.get(query);
+ if (isExactMatch) {
+ filteredCandidates = candidates;
+ } else {
+ let shouldCache = true;
+ if (query.includes('*') || !this.fzf) {
+ filteredCandidates = await filter(candidates, query, options.signal);
+ } else {
+ filteredCandidates = await this.fzf
+ .find(query)
+ .then((results: Array>) =>
+ results.map((entry: FzfResultItem) => entry.item),
+ )
+ .catch((e: unknown) => {
+ if (e instanceof Error && e.name === 'AbortError') throw e;
+ shouldCache = false;
+ return [];
+ });
+ }
+ if (shouldCache) {
+ this.resultCache!.set(query, filteredCandidates);
+ }
+ }
+ }
+
+ const results: string[] = [];
+ for (const [i, candidate] of filteredCandidates.entries()) {
+ if (i % 1000 === 0) {
+ await new Promise((resolve) => setImmediate(resolve));
+ if (options.signal?.aborted) {
+ throw new AbortError();
+ }
+ }
+ if (results.length >= (options.maxResults ?? Infinity)) {
+ break;
+ }
+ if (candidate === '.') {
+ continue;
+ }
+ if (!fileFilter(candidate)) {
+ results.push(candidate);
+ }
+ }
+ return results;
+ }
+
+ get snapshotSize(): number {
+ return this.allFiles.length;
+ }
+
+ get isReady(): boolean {
+ return this.crawlDone;
+ }
+}
diff --git a/packages/core/src/utils/filesearch/fileIndexProtocol.ts b/packages/core/src/utils/filesearch/fileIndexProtocol.ts
new file mode 100644
index 00000000000..a9e70356baf
--- /dev/null
+++ b/packages/core/src/utils/filesearch/fileIndexProtocol.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * IPC message shapes shared between the main-thread `FileIndexService` and
+ * the worker-thread `fileIndexWorker`. Defined in one place so the two
+ * sides cannot silently diverge — a new message variant here becomes a
+ * compile error on whichever side forgot to handle it.
+ */
+
+export type WorkerRequest =
+ | { type: 'start' }
+ | {
+ type: 'search';
+ reqId: string;
+ pattern: string;
+ maxResults?: number;
+ }
+ | { type: 'abort'; reqId: string }
+ | { type: 'dispose' };
+
+export type WorkerResponse =
+ | { type: 'partial'; chunk: string[] }
+ | { type: 'ready'; total: number }
+ | { type: 'crawlError'; error: string }
+ | { type: 'searchResult'; reqId: string; results: string[] }
+ | { type: 'searchError'; reqId: string; error: string; name: string };
diff --git a/packages/core/src/utils/filesearch/fileIndexService.test.ts b/packages/core/src/utils/filesearch/fileIndexService.test.ts
new file mode 100644
index 00000000000..0d229d9f34b
--- /dev/null
+++ b/packages/core/src/utils/filesearch/fileIndexService.test.ts
@@ -0,0 +1,229 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
+import {
+ FileIndexService,
+ __setIndexTransportFactory,
+ installInProcessIndexTransport,
+} from './fileIndexService.js';
+import {
+ cleanupTmpDir,
+ createTmpDir,
+} from '../../test-utils/file-system-test-helpers.js';
+
+describe('FileIndexService', () => {
+ // Vitest executes TS sources directly, so the worker-thread backend
+ // (which imports the compiled `fileIndexWorker.js`) can't spawn here.
+ // Swap to the in-process backend for the duration of this file and
+ // restore the default factory when the suite ends.
+ let restoreTransport: (() => void) | null = null;
+ beforeAll(() => {
+ restoreTransport = installInProcessIndexTransport();
+ });
+ afterAll(() => {
+ restoreTransport?.();
+ restoreTransport = null;
+ });
+
+ let tmpDir: string;
+ afterEach(async () => {
+ // Reset FIRST — cleanupTmpDir on Windows fails with EBUSY if the
+ // in-process transport's ripgrep child still holds a handle on the
+ // directory it was invoked with. Tearing down the service (and with
+ // it, the rg subprocess) before rmdir lets the OS release handles.
+ await FileIndexService.__resetForTests();
+ if (tmpDir) await cleanupTmpDir(tmpDir);
+ });
+
+ const baseOptions = (projectRoot: string) => ({
+ projectRoot,
+ ignoreDirs: [] as string[],
+ useGitignore: false,
+ useQwenignore: false,
+ cache: false,
+ cacheTtl: 0,
+ enableRecursiveFileSearch: true,
+ enableFuzzySearch: true,
+ });
+
+ it('returns the same instance for identical options (singleton)', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+ const opts = baseOptions(tmpDir);
+ const a = FileIndexService.for(opts);
+ const b = FileIndexService.for({ ...opts });
+ expect(a).toBe(b);
+ });
+
+ it('creates distinct instances for different project roots', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+ const other = await createTmpDir({ 'b.txt': '' });
+ try {
+ const a = FileIndexService.for(baseOptions(tmpDir));
+ const b = FileIndexService.for(baseOptions(other));
+ expect(a).not.toBe(b);
+ } finally {
+ await cleanupTmpDir(other);
+ }
+ });
+
+ it('transitions to ready and fires whenReady', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '', 'b.txt': '' });
+ const svc = FileIndexService.for(baseOptions(tmpDir));
+ await svc.whenReady();
+ expect(svc.state).toBe('ready');
+ expect(svc.snapshotSize).toBeGreaterThan(0);
+ });
+
+ it('delivers search results through the transport', async () => {
+ tmpDir = await createTmpDir({
+ src: { 'alpha.txt': '', 'beta.txt': '' },
+ });
+ const svc = FileIndexService.for(baseOptions(tmpDir));
+ await svc.whenReady();
+ const results = await svc.search('alpha');
+ expect(results).toContain('src/alpha.txt');
+ });
+
+ it('notifies onPartial subscribers as the snapshot grows', async () => {
+ const structure: Record = {};
+ for (let i = 0; i < 20; i++) structure[`f${i}.txt`] = '';
+ tmpDir = await createTmpDir(structure);
+
+ const svc = FileIndexService.for(baseOptions(tmpDir));
+ const observedCounts: number[] = [];
+ const unsubscribe = svc.onPartial((n) => observedCounts.push(n));
+ await svc.whenReady();
+ unsubscribe();
+
+ // At least one partial notification must have fired; counts must be
+ // monotonically non-decreasing; final count must match snapshotSize.
+ expect(observedCounts.length).toBeGreaterThan(0);
+ for (let i = 1; i < observedCounts.length; i++) {
+ expect(observedCounts[i]).toBeGreaterThanOrEqual(observedCounts[i - 1]);
+ }
+ expect(observedCounts[observedCounts.length - 1]).toBe(svc.snapshotSize);
+ });
+
+ it('propagates AbortError when a search signal fires', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+ const svc = FileIndexService.for(baseOptions(tmpDir));
+ await svc.whenReady();
+
+ const controller = new AbortController();
+ controller.abort();
+ await expect(
+ svc.search('a', { signal: controller.signal }),
+ ).rejects.toMatchObject({ name: 'AbortError' });
+ });
+
+ it('rejects whenReady() waiters on dispose', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+ const svc = FileIndexService.for(baseOptions(tmpDir));
+ const readyPromise = svc.whenReady();
+ // Dispose before whenReady could resolve.
+ await svc.dispose();
+ await expect(readyPromise).rejects.toMatchObject({ name: 'AbortError' });
+ });
+
+ it('yields a fresh instance after a previous one was disposed', async () => {
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+ const a = FileIndexService.for(baseOptions(tmpDir));
+ await a.dispose();
+ const b = FileIndexService.for(baseOptions(tmpDir));
+ expect(b).not.toBe(a);
+ await b.whenReady();
+ expect(b.state).toBe('ready');
+ });
+
+ it('rejects whenReady() called after the transport has exited', async () => {
+ // Regression: an 'exit' event before any `whenReady()` call used to leave
+ // `_state` stuck at 'crawling', so a later `whenReady()` parked in
+ // `readyWaiters` and never settled. With the fix, handleExit transitions
+ // the service to 'error' and future `whenReady()` calls reject
+ // synchronously.
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+
+ // Fake transport that captures the exit callback so the test can fire an
+ // early exit deterministically — before any `whenReady()` call subscribes.
+ const exitListeners: Array<(code: number) => void> = [];
+ const restore = __setIndexTransportFactory(() => ({
+ post: () => {},
+ onMessage: () => () => {},
+ onExit: (cb) => {
+ exitListeners.push(cb);
+ return () => {
+ const i = exitListeners.indexOf(cb);
+ if (i >= 0) exitListeners.splice(i, 1);
+ };
+ },
+ terminate: async () => {},
+ }));
+ try {
+ const svc = FileIndexService.for(baseOptions(tmpDir));
+ // Fire the exit before any `whenReady()` caller subscribes.
+ for (const cb of exitListeners) cb(1);
+ await expect(svc.whenReady()).rejects.toThrow(/File index worker/i);
+ expect(svc.state).toBe('error');
+ } finally {
+ restore();
+ }
+ });
+
+ it('invalidates the singleton when ignore rules change', async () => {
+ const fs = await import('node:fs/promises');
+ const path = await import('node:path');
+ tmpDir = await createTmpDir({ 'a.txt': '' });
+
+ const a = FileIndexService.for({
+ ...baseOptions(tmpDir),
+ useGitignore: true,
+ });
+ await a.whenReady();
+
+ // Write a .gitignore after the service was created; a subsequent `.for()`
+ // call must see a different options key and spawn a fresh worker rather
+ // than returning the memoised instance with stale ignore rules.
+ await fs.writeFile(path.join(tmpDir, '.gitignore'), 'ignored/\n', 'utf8');
+
+ const b = FileIndexService.for({
+ ...baseOptions(tmpDir),
+ useGitignore: true,
+ });
+ expect(b).not.toBe(a);
+ // And — regression test — the stale instance must have been disposed, so
+ // its worker doesn't linger in INSTANCES keyed under the old fingerprint.
+ // Post-dispose search throws a plain Error (not AbortError) so that
+ // callers like useAtCompletion, which silently swallow AbortError, don't
+ // accidentally hide this caller-misuse signal.
+ await expect(a.search('a')).rejects.toThrow(/disposed/i);
+ });
+
+ it('evicts the oldest instance when the LRU cap is exceeded', async () => {
+ const dirs: string[] = [];
+ try {
+ // The cap is 8; create 9 distinct project roots and confirm the first
+ // one gets disposed (LRU) while the rest remain live. Dispose happens
+ // asynchronously inside `.for()`, so we give the event loop a tick.
+ const services: FileIndexService[] = [];
+ for (let i = 0; i < 9; i++) {
+ const d = await createTmpDir({ [`f${i}.txt`]: '' });
+ dirs.push(d);
+ services.push(FileIndexService.for(baseOptions(d)));
+ }
+ await new Promise((resolve) => setImmediate(resolve));
+
+ // First one (LRU victim) should now be disposed — searching against it
+ // throws the "disposed" error.
+ await expect(services[0].search('f')).rejects.toThrow(/disposed/i);
+ // The tail of the list should remain live.
+ await services[8].whenReady();
+ expect(services[8].state).toBe('ready');
+ } finally {
+ for (const d of dirs) await cleanupTmpDir(d);
+ }
+ });
+});
diff --git a/packages/core/src/utils/filesearch/fileIndexService.ts b/packages/core/src/utils/filesearch/fileIndexService.ts
new file mode 100644
index 00000000000..dcec1ac97ec
--- /dev/null
+++ b/packages/core/src/utils/filesearch/fileIndexService.ts
@@ -0,0 +1,586 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import crypto from 'node:crypto';
+import { Worker } from 'node:worker_threads';
+import { FileIndexCore } from './fileIndexCore.js';
+import type { FileSearchOptions, SearchOptions } from './fileSearch.js';
+import { AbortError } from './fileSearch.js';
+import { loadIgnoreRules } from './ignore.js';
+import type { WorkerRequest, WorkerResponse } from './fileIndexProtocol.js';
+
+type ServiceState = 'crawling' | 'ready' | 'error';
+
+/**
+ * Abstraction over the transport between `FileIndexService` and the file
+ * index engine. The default backend spawns a Node.js worker thread; tests
+ * and environments where worker spawning is problematic (e.g. vitest with
+ * TypeScript sources) use an in-process backend that executes `FileIndexCore`
+ * on the main thread behind the same message interface.
+ */
+interface IndexTransport {
+ post(msg: WorkerRequest): void;
+ onMessage(cb: (msg: WorkerResponse) => void): () => void;
+ onExit(cb: (code: number) => void): () => void;
+ terminate(): Promise;
+}
+
+function createWorkerTransport(options: FileSearchOptions): IndexTransport {
+ const worker = new Worker(new URL('./fileIndexWorker.js', import.meta.url), {
+ workerData: { options },
+ });
+ let dead = false;
+ const exitListeners = new Set<(code: number) => void>();
+
+ // An uncaught error inside the worker fires 'error' and typically 'exit'
+ // right after. Without an 'error' handler Node re-throws on the main
+ // process and crashes the CLI; convert it into a synthetic non-zero exit
+ // so handleExit cleanup runs uniformly. We also listen for 'exit' itself.
+ const forwardExit = (code: number) => {
+ if (dead) return;
+ dead = true;
+ exitListeners.forEach((cb) => cb(code));
+ };
+ worker.on('error', (err) => {
+ // Surface the crash to stderr once so it's diagnosable, then drive the
+ // normal exit-cleanup path. Without this handler Node re-emits the
+ // error on the main process and tears down the CLI. Log only name +
+ // message (not the full Error with its stack of absolute paths) to
+ // keep the output transcript clean if the CLI is run under a wrapper.
+ const summary =
+ err instanceof Error ? `${err.name}: ${err.message}` : String(err);
+ // eslint-disable-next-line no-console
+ console.error('[fileIndexWorker] uncaught error:', summary);
+ forwardExit(1);
+ });
+ worker.on('exit', (code) => forwardExit(code));
+
+ return {
+ post: (msg) => {
+ // postMessage on a terminated worker throws synchronously; swallow it
+ // so service callers fail via the pending-promise rejection path
+ // instead of bubbling a ThreadStoppedError up the call stack.
+ if (dead) return;
+ try {
+ worker.postMessage(msg);
+ } catch {
+ // ignore; exit cleanup will surface this to pending callers
+ }
+ },
+ onMessage: (cb) => {
+ const listener = (m: WorkerResponse) => cb(m);
+ worker.on('message', listener);
+ return () => worker.off('message', listener);
+ },
+ onExit: (cb) => {
+ exitListeners.add(cb);
+ return () => exitListeners.delete(cb);
+ },
+ terminate: async () => {
+ await worker.terminate();
+ },
+ };
+}
+
+function createInProcessTransport(options: FileSearchOptions): IndexTransport {
+ const core = new FileIndexCore(options);
+ const listeners = new Set<(msg: WorkerResponse) => void>();
+ const exitListeners = new Set<(code: number) => void>();
+ const inflight = new Map();
+ let started = false;
+ let disposed = false;
+
+ const emit = (msg: WorkerResponse) => {
+ // Deliver asynchronously so subscribers resemble the real worker timing.
+ setImmediate(() => {
+ if (disposed) return;
+ listeners.forEach((cb) => cb(msg));
+ });
+ };
+
+ return {
+ post: (msg) => {
+ if (disposed) return;
+ switch (msg.type) {
+ case 'start': {
+ if (started) return;
+ started = true;
+ (async () => {
+ try {
+ await core.startCrawl((chunk) =>
+ emit({ type: 'partial', chunk }),
+ );
+ core.buildFzfIndex();
+ emit({ type: 'ready', total: core.snapshotSize });
+ } catch (e) {
+ emit({
+ type: 'crawlError',
+ error: e instanceof Error ? e.message : String(e),
+ });
+ }
+ })();
+ return;
+ }
+ case 'search': {
+ const controller = new AbortController();
+ inflight.set(msg.reqId, controller);
+ (async () => {
+ try {
+ const results = await core.search(msg.pattern, {
+ signal: controller.signal,
+ maxResults: msg.maxResults,
+ });
+ emit({ type: 'searchResult', reqId: msg.reqId, results });
+ } catch (e) {
+ const error = e instanceof Error ? e : new Error(String(e));
+ emit({
+ type: 'searchError',
+ reqId: msg.reqId,
+ error: error.message,
+ name: error.name,
+ });
+ } finally {
+ inflight.delete(msg.reqId);
+ }
+ })();
+ return;
+ }
+ case 'abort':
+ inflight.get(msg.reqId)?.abort();
+ return;
+ case 'dispose':
+ disposed = true;
+ inflight.forEach((c) => c.abort());
+ inflight.clear();
+ exitListeners.forEach((cb) => cb(0));
+ return;
+ default:
+ return;
+ }
+ },
+ onMessage: (cb) => {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ onExit: (cb) => {
+ exitListeners.add(cb);
+ return () => exitListeners.delete(cb);
+ },
+ terminate: async () => {
+ disposed = true;
+ inflight.forEach((c) => c.abort());
+ inflight.clear();
+ exitListeners.forEach((cb) => cb(0));
+ },
+ };
+}
+
+let transportFactory: (options: FileSearchOptions) => IndexTransport =
+ createWorkerTransport;
+
+/**
+ * Override the transport factory. Intended for tests that need to exercise
+ * the in-process backend or inject a fake. Returns a restore function.
+ */
+export function __setIndexTransportFactory(
+ factory: (options: FileSearchOptions) => IndexTransport,
+): () => void {
+ const prev = transportFactory;
+ transportFactory = factory;
+ return () => {
+ transportFactory = prev;
+ };
+}
+
+/**
+ * Installs the in-process backend as the default transport. Useful for
+ * embedders that can't spawn worker threads (e.g. certain test runners
+ * executing TypeScript sources directly, or hardened sandboxes). Prefer
+ * this over the process.env-sniffing we used to do — it makes the decision
+ * explicit at the call site and survives bundling.
+ */
+export function installInProcessIndexTransport(): () => void {
+ return __setIndexTransportFactory(createInProcessTransport);
+}
+
+export interface FileIndexServiceState {
+ state: ServiceState;
+ snapshotSize: number;
+}
+
+/**
+ * Upper bound on cached `FileIndexService` singletons. Each instance owns a
+ * worker thread (~10–30 MB of JS heap plus the fzf index), so multi-root
+ * workspaces or rapid `cd` across many projects could accumulate them
+ * unboundedly. We evict the least-recently-used (by insertion order: `Map`
+ * preserves it, and `.for()` re-inserts on hit — see below) once we exceed
+ * this cap. Picked conservatively: nobody has more than a handful of active
+ * project roots at once, but the cap is high enough that normal tabbed
+ * workflows never hit it.
+ */
+const MAX_INSTANCES = 8;
+const INSTANCES = new Map();
+
+function optionsKey(options: FileSearchOptions): string {
+ // Include the ignore-rule content (via loadIgnoreRules().getFingerprint(),
+ // which hashes .gitignore + .qwenignore + ignoreDirs) so that editing
+ // those files produces a different key and spawns a fresh worker. Without
+ // this, a stale singleton would keep serving results that still match
+ // the old patterns. `loadIgnoreRules` is sync-fs; the cost is tiny (two
+ // existsSync + at-most-two readFileSync) and only runs on `.for()` miss
+ // paths (it's called again from FileIndexCore inside the worker).
+ let ignoreFingerprint = '';
+ try {
+ ignoreFingerprint = loadIgnoreRules(options).getFingerprint();
+ } catch {
+ // If the project root is unreadable, fall back to the path only. The
+ // worker will fail its own crawl in that case and surface the error.
+ }
+ const serializable = {
+ projectRoot: options.projectRoot,
+ ignoreDirs: [...options.ignoreDirs].sort(),
+ useGitignore: options.useGitignore,
+ useQwenignore: options.useQwenignore,
+ enableFuzzySearch: options.enableFuzzySearch,
+ enableRecursiveFileSearch: options.enableRecursiveFileSearch,
+ maxDepth: options.maxDepth ?? null,
+ ignoreFingerprint,
+ };
+ return crypto
+ .createHash('sha256')
+ .update(JSON.stringify(serializable))
+ .digest('hex');
+}
+
+/**
+ * Owns the file index worker for a given project. Callers obtain a singleton
+ * instance per unique options hash via `FileIndexService.for(...)`. The
+ * instance spins up immediately and begins crawling; `search()` can be
+ * invoked at any time and will be served against whatever snapshot has been
+ * streamed in so far.
+ */
+export class FileIndexService {
+ static for(options: FileSearchOptions): FileIndexService {
+ const key = optionsKey(options);
+ const existing = INSTANCES.get(key);
+ if (existing && !existing.disposed) {
+ // Touch: re-insert to make this the most-recently-used entry for LRU
+ // eviction purposes.
+ INSTANCES.delete(key);
+ INSTANCES.set(key, existing);
+ return existing;
+ }
+ // Before minting a fresh singleton, evict any previous instance keyed
+ // under a *different* hash for the same `projectRoot`. This happens
+ // when .gitignore/.qwenignore is edited: the content changes the
+ // fingerprint, so the old key no longer matches — without eviction
+ // the stale worker stays alive forever with outdated ignore rules.
+ for (const [staleKey, staleInst] of INSTANCES) {
+ if (staleKey === key) continue;
+ if (staleInst.projectRoot !== options.projectRoot) continue;
+ // Fire-and-forget; dispose() is idempotent and removes the entry from
+ // INSTANCES synchronously at its start so the current iteration and
+ // future lookups won't see it.
+ void staleInst.dispose();
+ }
+ const instance = new FileIndexService(options, key);
+ // If the transport errored synchronously inside the constructor (e.g.
+ // Worker spawn throws because of a sandbox restriction), handleExit
+ // already ran and called `INSTANCES.delete(this.key)` against an entry
+ // that wasn't there yet. Guard the set so a permanently-disposed
+ // instance isn't memoised for every future `.for()` caller.
+ if (!instance.disposed) {
+ INSTANCES.set(key, instance);
+ // LRU cap: insertion order in a `Map` matches access order because
+ // the early-return branch above re-inserts on hit. When we overflow,
+ // `keys().next()` is the oldest untouched instance.
+ while (INSTANCES.size > MAX_INSTANCES) {
+ const oldestKey = INSTANCES.keys().next().value;
+ if (oldestKey === undefined || oldestKey === key) break;
+ const victim = INSTANCES.get(oldestKey);
+ if (!victim) {
+ INSTANCES.delete(oldestKey);
+ continue;
+ }
+ void victim.dispose(); // also deletes its own entry synchronously
+ }
+ }
+ return instance;
+ }
+
+ /** For tests: drop all cached singletons and dispose them. */
+ static async __resetForTests(): Promise {
+ const pending: Array> = [];
+ for (const inst of INSTANCES.values()) pending.push(inst.dispose());
+ INSTANCES.clear();
+ await Promise.all(pending);
+ }
+
+ private transport: IndexTransport;
+ private _state: ServiceState = 'crawling';
+ private _snapshotSize = 0;
+ private pending = new Map<
+ string,
+ { resolve: (r: string[]) => void; reject: (e: Error) => void }
+ >();
+ private partialSubs = new Set<(snapshotSize: number) => void>();
+ private readySubs = new Set<() => void>();
+ private readyWaiters: Array<{
+ resolve: () => void;
+ reject: (err: Error) => void;
+ }> = [];
+ private nextReqId = 0;
+ private disposed = false;
+ private unsubscribeMessage: () => void;
+ private unsubscribeExit: () => void;
+
+ readonly projectRoot: string;
+
+ private constructor(
+ options: FileSearchOptions,
+ private readonly key: string,
+ ) {
+ this.projectRoot = options.projectRoot;
+ this.transport = transportFactory(options);
+ this.unsubscribeMessage = this.transport.onMessage(this.handleMessage);
+ this.unsubscribeExit = this.transport.onExit(this.handleExit);
+ this.transport.post({ type: 'start' });
+ }
+
+ get state(): ServiceState {
+ return this._state;
+ }
+
+ get snapshotSize(): number {
+ return this._snapshotSize;
+ }
+
+ /**
+ * Subscribe to partial snapshot growth. The callback fires on every
+ * streamed chunk (with the running total) and once more when the crawl
+ * completes. Returns an unsubscribe function.
+ */
+ onPartial(cb: (snapshotSize: number) => void): () => void {
+ this.partialSubs.add(cb);
+ return () => {
+ this.partialSubs.delete(cb);
+ };
+ }
+
+ /** Subscribe to the single "crawl done" event. */
+ onReady(cb: () => void): () => void {
+ if (this._state === 'ready') {
+ setImmediate(cb);
+ return () => {};
+ }
+ this.readySubs.add(cb);
+ return () => {
+ this.readySubs.delete(cb);
+ };
+ }
+
+ /**
+ * Resolves once the initial crawl has finished (or rejects if the worker
+ * errored or exited). Used by the `FileSearch` proxy to preserve its
+ * original "initialize awaits full readiness" contract.
+ */
+ whenReady(): Promise {
+ if (this._state === 'ready') return Promise.resolve();
+ if (this._state === 'error')
+ return Promise.reject(new Error('File index worker errored'));
+ return new Promise((resolve, reject) => {
+ this.readyWaiters.push({ resolve, reject });
+ });
+ }
+
+ async search(
+ pattern: string,
+ options: SearchOptions = {},
+ ): Promise {
+ // Deliberately NOT an AbortError here. In-flight searches rejected from
+ // inside dispose() are AbortErrors because the caller's request was
+ // cancelled. This path, by contrast, is a caller misuse (calling search
+ // after dispose) — and useAtCompletion's catch block silently swallows
+ // AbortError as "user typed ESC", which would hide the disposed-service
+ // signal and leave the UI stuck in SEARCHING. A plain Error correctly
+ // drives the ERROR dispatch branch.
+ if (this.disposed) throw new Error('FileIndexService has been disposed');
+
+ const reqId = `r${this.nextReqId++}`;
+ return new Promise((resolve, reject) => {
+ this.pending.set(reqId, { resolve, reject });
+
+ if (options.signal) {
+ if (options.signal.aborted) {
+ this.pending.delete(reqId);
+ const e = new Error('Search aborted');
+ e.name = 'AbortError';
+ reject(e);
+ return;
+ }
+ const onAbort = () => {
+ this.transport.post({ type: 'abort', reqId });
+ };
+ options.signal.addEventListener('abort', onAbort, { once: true });
+ // Clean up the abort listener once the request settles.
+ const entry = this.pending.get(reqId)!;
+ const origResolve = entry.resolve;
+ const origReject = entry.reject;
+ entry.resolve = (r) => {
+ options.signal?.removeEventListener('abort', onAbort);
+ origResolve(r);
+ };
+ entry.reject = (e) => {
+ options.signal?.removeEventListener('abort', onAbort);
+ origReject(e);
+ };
+ }
+
+ this.transport.post({
+ type: 'search',
+ reqId,
+ pattern,
+ maxResults: options.maxResults,
+ });
+ });
+ }
+
+ async dispose(): Promise {
+ if (this.disposed) return;
+ this.disposed = true;
+ INSTANCES.delete(this.key);
+ // Unsubscribe BEFORE posting the dispose message: the in-process
+ // transport runs its exit cleanup synchronously inside `post('dispose')`,
+ // which would otherwise invoke `handleExit` and reject waiters with a
+ // plain "worker exited" Error, beating the AbortError rejection below.
+ this.unsubscribeMessage();
+ this.unsubscribeExit();
+ this.transport.post({ type: 'dispose' });
+ // Race terminate against a short timeout so a faulted worker can't hang
+ // dispose() indefinitely. `terminate()` normally resolves in well under
+ // 100ms; 2s is generous enough that healthy workers always win. On
+ // timeout we surface a warning and re-issue `terminate()` as a
+ // best-effort force-kill — worker_threads' `terminate()` is idempotent,
+ // so calling it twice just queues another tear-down attempt.
+ let timedOut = false;
+ let timer: ReturnType | undefined;
+ await Promise.race([
+ this.transport.terminate().then(() => {
+ // Healthy path: clear the pending timer so it doesn't keep the
+ // event loop alive (vitest's matrix workers would otherwise hang
+ // on exit waiting for the 2s handle to fire — see #3455).
+ if (timer) clearTimeout(timer);
+ }),
+ new Promise((resolve) => {
+ timer = setTimeout(() => {
+ timedOut = true;
+ resolve();
+ }, 2000);
+ // Belt-and-suspenders: even if the clear above is missed for any
+ // reason, `.unref()` tells Node this timer shouldn't block the
+ // process from exiting.
+ timer.unref?.();
+ }),
+ ]);
+ if (timedOut) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '[FileIndexService] worker terminate() timed out after 2s; retrying force-kill',
+ );
+ // Fire-and-forget retry. The returned promise is intentionally not
+ // awaited — we don't want dispose() to keep the caller blocked on a
+ // hung worker, and the pending-rejection cleanup below still runs.
+ void this.transport.terminate().catch(() => {});
+ }
+ const err = new AbortError('FileIndexService disposed');
+ this.pending.forEach(({ reject }) => reject(err));
+ this.pending.clear();
+ const waiters = this.readyWaiters.splice(0);
+ waiters.forEach((w) => w.reject(err));
+ }
+
+ private handleMessage = (msg: WorkerResponse) => {
+ switch (msg.type) {
+ case 'partial':
+ this._snapshotSize += msg.chunk.length;
+ this.partialSubs.forEach((cb) => cb(this._snapshotSize));
+ return;
+ case 'ready': {
+ this._state = 'ready';
+ this._snapshotSize = msg.total;
+ this.partialSubs.forEach((cb) => cb(msg.total));
+ this.readySubs.forEach((cb) => cb());
+ this.readySubs.clear();
+ const waiters = this.readyWaiters.splice(0);
+ waiters.forEach((w) => w.resolve());
+ return;
+ }
+ case 'crawlError': {
+ this._state = 'error';
+ const err = new Error(msg.error);
+ const rejectees = this.readyWaiters.splice(0);
+ rejectees.forEach((w) => w.reject(err));
+ // Also fail any in-flight searches — without a successful crawl the
+ // worker has only a partial snapshot and callers usually want to
+ // surface the failure rather than silently see fewer results.
+ this.pending.forEach(({ reject }) => reject(err));
+ this.pending.clear();
+ // Tear the service down so a subsequent FileIndexService.for() call
+ // starts a fresh worker. Otherwise this instance lingers in
+ // INSTANCES with a live worker that will reject every whenReady()
+ // forever, leaking a thread per crawl failure.
+ void this.dispose();
+ return;
+ }
+ case 'searchResult': {
+ const pend = this.pending.get(msg.reqId);
+ if (!pend) return;
+ this.pending.delete(msg.reqId);
+ pend.resolve(msg.results);
+ return;
+ }
+ case 'searchError': {
+ const pend = this.pending.get(msg.reqId);
+ if (!pend) return;
+ this.pending.delete(msg.reqId);
+ // Preserve AbortError class identity so callers can use `instanceof`.
+ // Other errors fall back to a plain Error with the original name.
+ let e: Error;
+ if (msg.name === 'AbortError') {
+ e = new AbortError(msg.error);
+ } else {
+ e = new Error(msg.error);
+ e.name = msg.name || 'Error';
+ }
+ pend.reject(e);
+ return;
+ }
+ default:
+ return;
+ }
+ };
+
+ private handleExit = (_code: number) => {
+ // If we already ran dispose(), its own cleanup has either rejected or
+ // will reject the pending maps with AbortError; don't double-reject with
+ // a worker-exited Error.
+ if (this.disposed) return;
+ // Mark the service errored so `whenReady()` calls arriving after this
+ // point reject synchronously instead of parking in readyWaiters forever.
+ // Without this, a caller that holds a `FileIndexService.for(...)`
+ // reference and invokes `whenReady()` just after an early worker exit
+ // would see `_state === 'crawling'` and never settle.
+ this._state = 'error';
+ const err = new Error('File index worker exited');
+ err.name = 'Error';
+ this.pending.forEach(({ reject }) => reject(err));
+ this.pending.clear();
+ const waiters = this.readyWaiters.splice(0);
+ waiters.forEach((w) => w.reject(err));
+ INSTANCES.delete(this.key);
+ this.disposed = true;
+ };
+}
diff --git a/packages/core/src/utils/filesearch/fileIndexWorker.ts b/packages/core/src/utils/filesearch/fileIndexWorker.ts
new file mode 100644
index 00000000000..a95c20eac36
--- /dev/null
+++ b/packages/core/src/utils/filesearch/fileIndexWorker.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { parentPort, workerData } from 'node:worker_threads';
+import { FileIndexCore } from './fileIndexCore.js';
+import type { FileSearchOptions } from './fileSearch.js';
+import type { WorkerRequest, WorkerResponse } from './fileIndexProtocol.js';
+
+if (!parentPort) {
+ throw new Error('fileIndexWorker must be launched as a Worker thread.');
+}
+
+const send = (msg: WorkerResponse) => parentPort!.postMessage(msg);
+
+// Constructing the core can throw (e.g. a projectRoot with a NUL byte causes
+// loadIgnoreRules → fs.existsSync to fault). Surface such failures via the
+// normal `crawlError` channel instead of letting the worker die with an
+// uncaught exception before its message handler is even attached — the
+// main-thread service then treats it identically to a crawl-time failure.
+let core: FileIndexCore | undefined;
+let initError: string | undefined;
+try {
+ core = new FileIndexCore(workerData.options as FileSearchOptions);
+} catch (e) {
+ initError = e instanceof Error ? e.message : String(e);
+}
+
+const inflightAborts = new Map();
+let started = false;
+
+parentPort.on('message', (msg: WorkerRequest) => {
+ if (initError || !core) {
+ // Every message before the main thread sees the crawlError would otherwise
+ // hang or produce confusing behaviour; reply with crawlError / searchError
+ // as appropriate and ignore abort/dispose (nothing to clean up).
+ if (msg?.type === 'start') {
+ send({ type: 'crawlError', error: initError ?? 'core init failed' });
+ } else if (msg?.type === 'search' && typeof msg.reqId === 'string') {
+ send({
+ type: 'searchError',
+ reqId: msg.reqId,
+ error: initError ?? 'core init failed',
+ name: 'Error',
+ });
+ }
+ return;
+ }
+ switch (msg.type) {
+ case 'start': {
+ if (started) return;
+ started = true;
+ (async () => {
+ try {
+ await core!.startCrawl((chunk) => send({ type: 'partial', chunk }));
+ core!.buildFzfIndex();
+ send({ type: 'ready', total: core!.snapshotSize });
+ } catch (e) {
+ send({
+ type: 'crawlError',
+ error: e instanceof Error ? e.message : String(e),
+ });
+ }
+ })();
+ return;
+ }
+ case 'search': {
+ const { reqId, pattern, maxResults } = msg;
+ // Defensive: reject malformed IPC shape instead of crashing the worker.
+ // In the current protocol reqId/pattern are always strings, but a
+ // future caller could desynchronise and we'd rather fail one request
+ // than all of them.
+ if (typeof reqId !== 'string') return;
+ if (typeof pattern !== 'string') {
+ send({
+ type: 'searchError',
+ reqId,
+ error: 'pattern must be a string',
+ name: 'TypeError',
+ });
+ return;
+ }
+ const controller = new AbortController();
+ inflightAborts.set(reqId, controller);
+ (async () => {
+ try {
+ const results = await core!.search(pattern, {
+ signal: controller.signal,
+ maxResults,
+ });
+ send({ type: 'searchResult', reqId, results });
+ } catch (e) {
+ const error = e instanceof Error ? e : new Error(String(e));
+ send({
+ type: 'searchError',
+ reqId,
+ error: error.message,
+ name: error.name,
+ });
+ } finally {
+ inflightAborts.delete(reqId);
+ }
+ })();
+ return;
+ }
+ case 'abort': {
+ if (typeof msg.reqId === 'string') {
+ inflightAborts.get(msg.reqId)?.abort();
+ }
+ return;
+ }
+ case 'dispose': {
+ // Abort any in-flight searches so their message handlers can finish
+ // posting their `searchError` reply before we tear down the channel.
+ inflightAborts.forEach((c) => c.abort());
+ inflightAborts.clear();
+ // Closing the message port lets Node drain any pending
+ // `postMessage` calls (searchError/searchResult replies queued in the
+ // current tick) before the worker actually exits. Using
+ // `process.exit(0)` would race those sends and occasionally drop them.
+ parentPort!.close();
+ return;
+ }
+ default: {
+ // Unknown message; ignore. Keeps worker forward-compatible.
+ return;
+ }
+ }
+});
diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts
index 265e9cfc94e..86922220e1e 100644
--- a/packages/core/src/utils/filesearch/fileSearch.test.ts
+++ b/packages/core/src/utils/filesearch/fileSearch.test.ts
@@ -4,16 +4,48 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, afterEach, vi } from 'vitest';
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from 'vitest';
import { FileSearchFactory, AbortError, filter } from './fileSearch.js';
+import {
+ FileIndexService,
+ installInProcessIndexTransport,
+} from './fileIndexService.js';
import {
createTmpDir,
cleanupTmpDir,
} from '../../test-utils/file-system-test-helpers.js';
describe('FileSearch', () => {
+ // Recursive (`enableRecursiveFileSearch: true`) searches route through
+ // FileIndexService, whose default transport spawns a worker_thread
+ // against the compiled `fileIndexWorker.js`. Under vitest's
+ // TS-direct-from-source execution that worker URL can't resolve — swap
+ // to the in-process backend for the duration of this suite.
+ let restoreTransport: (() => void) | null = null;
+ beforeAll(() => {
+ restoreTransport = installInProcessIndexTransport();
+ });
+ afterAll(async () => {
+ await FileIndexService.__resetForTests();
+ restoreTransport?.();
+ restoreTransport = null;
+ });
+
let tmpDir: string;
afterEach(async () => {
+ // Drop any live singletons that were created during this test so the
+ // next test's tmpDir cleanup doesn't race an open rg child process
+ // (Windows in particular: EBUSY on rmdir while the ripgrep subprocess
+ // still holds a handle).
+ await FileIndexService.__resetForTests();
if (tmpDir) {
await cleanupTmpDir(tmpDir);
}
diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts
index b277f1df9bd..144d29c30e9 100644
--- a/packages/core/src/utils/filesearch/fileSearch.ts
+++ b/packages/core/src/utils/filesearch/fileSearch.ts
@@ -8,20 +8,8 @@ import path from 'node:path';
import picomatch from 'picomatch';
import type { Ignore } from './ignore.js';
import { loadIgnoreRules } from './ignore.js';
-import { ResultCache } from './result-cache.js';
import { crawl } from './crawler.js';
-import type { FzfResultItem } from 'fzf';
-import { AsyncFzf } from 'fzf';
-import { unescapePath } from '../paths.js';
-
-/**
- * Safety cap on the number of file entries the recursive crawler will
- * materialise in memory. Without this, workspaces with millions of files
- * (e.g. missing .gitignore, huge node_modules trees) can push Node.js past
- * its heap limit and crash with an OOM. 100 000 entries is generous enough
- * for virtually all real projects while keeping peak memory well under 100 MB.
- */
-const MAX_CRAWL_FILES = 100_000;
+import { FileIndexService } from './fileIndexService.js';
export interface FileSearchOptions {
projectRoot: string;
@@ -54,11 +42,22 @@ export async function filter(
pattern: string,
signal: AbortSignal | undefined,
): Promise {
- const patternFilter = picomatch(pattern, {
- dot: true,
- contains: true,
- nocase: true,
- });
+ // picomatch throws on malformed globs (unmatched `[`, pathological
+ // extglob nesting, etc.). A user typing inside the @-picker can easily
+ // hit an interim state like `foo[` that isn't valid yet — we treat that
+ // as "no matches" rather than propagating a TypeError that would crash
+ // the caller. picomatch errors are synchronous at compile time; runtime
+ // matching cannot throw.
+ let patternFilter: (p: string) => boolean;
+ try {
+ patternFilter = picomatch(pattern, {
+ dot: true,
+ contains: true,
+ nocase: true,
+ });
+ } catch {
+ return [];
+ }
const results: string[] = [];
for (const [i, p] of allPaths.entries()) {
@@ -98,108 +97,58 @@ export interface SearchOptions {
export interface FileSearch {
initialize(): Promise;
search(pattern: string, options?: SearchOptions): Promise;
+ /**
+ * Release any resources held by this instance. For the recursive (worker-
+ * backed) implementation this tears down the shared FileIndexService so the
+ * next `FileSearchFactory.create(...)` call gets a fresh worker — callers
+ * should invoke this when filesystem events (e.g. a watcher reporting file
+ * create/delete) would otherwise leave the indexed snapshot stale.
+ * Optional to implement for backward compatibility; callers that didn't
+ * previously call dispose() don't need to start.
+ */
+ dispose?(): Promise;
}
+/**
+ * Thin proxy over a shared {@link FileIndexService}. Prior to P1 this class
+ * owned the crawl, the fzf index, and the result cache on the main thread,
+ * which could block the Ink render loop for hundreds of milliseconds on
+ * large monorepos. Those responsibilities now live in a worker thread
+ * managed by FileIndexService; the proxy is kept so existing callers and
+ * the public `FileSearch` interface are unchanged.
+ */
class RecursiveFileSearch implements FileSearch {
- private ignore: Ignore | undefined;
- private resultCache: ResultCache | undefined;
- private allFiles: string[] = [];
- private fzf: AsyncFzf | undefined;
+ private service: FileIndexService | undefined;
constructor(private readonly options: FileSearchOptions) {}
async initialize(): Promise {
- this.ignore = loadIgnoreRules(this.options);
- this.allFiles = await crawl({
- crawlDirectory: this.options.projectRoot,
- cwd: this.options.projectRoot,
- ignore: this.ignore,
- cache: this.options.cache,
- cacheTtl: this.options.cacheTtl,
- maxDepth: this.options.maxDepth,
- maxFiles: MAX_CRAWL_FILES,
- });
- this.buildResultCache();
+ // Grab-or-create the shared service. The crawl starts eagerly inside
+ // the worker. We wait for `whenReady()` here so the public contract
+ // ("after initialize, search results are complete") is preserved for
+ // existing callers like vscode-ide-companion. This no longer blocks
+ // the main thread because the heavy work happens inside the worker;
+ // Ink can render "loading" state while the promise is pending.
+ // Streaming-aware callers (e.g. useAtCompletion) go straight to
+ // `FileIndexService.for(...)` to bypass this wait.
+ this.service = FileIndexService.for(this.options);
+ await this.service.whenReady();
}
async search(
pattern: string,
options: SearchOptions = {},
): Promise {
- // Check if engine is properly initialized.
- // If fuzzy search is enabled (or undefined, default true), fzf must be initialized.
- if (
- !this.resultCache ||
- (!this.fzf && this.options.enableFuzzySearch !== false) ||
- !this.ignore
- ) {
+ if (!this.service) {
throw new Error('Engine not initialized. Call initialize() first.');
}
-
- pattern = unescapePath(pattern) || '*';
-
- let filteredCandidates;
- const { files: candidates, isExactMatch } =
- await this.resultCache!.get(pattern);
-
- if (isExactMatch) {
- // Use the cached result.
- filteredCandidates = candidates;
- } else {
- let shouldCache = true;
- if (pattern.includes('*') || !this.fzf) {
- filteredCandidates = await filter(candidates, pattern, options.signal);
- } else {
- filteredCandidates = await this.fzf
- .find(pattern)
- .then((results: Array>) =>
- results.map((entry: FzfResultItem) => entry.item),
- )
- .catch(() => {
- shouldCache = false;
- return [];
- });
- }
-
- if (shouldCache) {
- this.resultCache!.set(pattern, filteredCandidates);
- }
- }
-
- const fileFilter = this.ignore.getFileFilter();
- const results: string[] = [];
- for (const [i, candidate] of filteredCandidates.entries()) {
- if (i % 1000 === 0) {
- await new Promise((resolve) => setImmediate(resolve));
- if (options.signal?.aborted) {
- throw new AbortError();
- }
- }
-
- if (results.length >= (options.maxResults ?? Infinity)) {
- break;
- }
- if (candidate === '.') {
- continue;
- }
- if (!fileFilter(candidate)) {
- results.push(candidate);
- }
- }
- return results;
+ return this.service.search(pattern, options);
}
- private buildResultCache(): void {
- this.resultCache = new ResultCache(this.allFiles);
- // Initialize fuzzy search if enabled (or undefined, default true).
- if (this.options.enableFuzzySearch !== false) {
- // The v1 algorithm is much faster since it only looks at the first
- // occurence of the pattern. We use it for search spaces that have >20k
- // files, because the v2 algorithm is just too slow in those cases.
- this.fzf = new AsyncFzf(this.allFiles, {
- fuzzy: this.allFiles.length > 20000 ? 'v1' : 'v2',
- });
- }
+ async dispose(): Promise {
+ const svc = this.service;
+ this.service = undefined;
+ await svc?.dispose();
}
}
diff --git a/packages/core/src/utils/filesearch/ripgrepCrawler.test.ts b/packages/core/src/utils/filesearch/ripgrepCrawler.test.ts
new file mode 100644
index 00000000000..7b8cb19a02c
--- /dev/null
+++ b/packages/core/src/utils/filesearch/ripgrepCrawler.test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { buildRipgrepFileFilter } from './ripgrepCrawler.js';
+import { loadIgnoreRules } from './ignore.js';
+import {
+ cleanupTmpDir,
+ createTmpDir,
+} from '../../test-utils/file-system-test-helpers.js';
+
+describe('buildRipgrepFileFilter', () => {
+ it('treats a bare "." or "./" as a no-op filter (does not throw)', async () => {
+ // Regression: on Windows, ripgrep emits paths with backslashes (".\\foo"),
+ // which the crawler converts to posix ("./foo") but previously forgot to
+ // strip the leading "./". The filter's ancestor-directory walk then fed
+ // "./" into the `ignore` library, which throws RangeError on an
+ // unrelativised path. That RangeError escaped the stdout data handler,
+ // wrecked the stream, and produced silent empty results under CI.
+ const tmpDir = await createTmpDir({ 'a.txt': '' });
+ try {
+ const filter = buildRipgrepFileFilter(
+ loadIgnoreRules({
+ projectRoot: tmpDir,
+ useGitignore: false,
+ useQwenignore: false,
+ ignoreDirs: [],
+ }),
+ );
+
+ expect(() => filter('.')).not.toThrow();
+ expect(() => filter('./')).not.toThrow();
+ expect(() => filter('')).not.toThrow();
+ // And — paths with a stray leading "./" (as from a mis-normalised
+ // Windows input) must not trip the dir-walker either.
+ expect(() => filter('./src/foo.ts')).not.toThrow();
+ } finally {
+ await cleanupTmpDir(tmpDir);
+ }
+ });
+});
diff --git a/packages/core/src/utils/filesearch/ripgrepCrawler.ts b/packages/core/src/utils/filesearch/ripgrepCrawler.ts
new file mode 100644
index 00000000000..55d45325ac7
--- /dev/null
+++ b/packages/core/src/utils/filesearch/ripgrepCrawler.ts
@@ -0,0 +1,387 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { resolveRipgrep } from '../ripgrepUtils.js';
+import type { Ignore } from './ignore.js';
+
+export interface RipgrepCrawlOptions {
+ /** Directory the crawl starts from. */
+ crawlDirectory: string;
+ /** Project's root; the returned paths are relative to this. */
+ cwd: string;
+ /** Hard cap on the number of paths materialised. Same safety valve as fdir. */
+ maxFiles?: number;
+ /** Extra directories to exclude beyond rg's gitignore handling (.qwenignore dirs, user ignoreDirs). */
+ extraExcludeDirs?: string[];
+ /**
+ * Post-filter applied to each path that survives rg's own ignore handling.
+ * Receives cwd-relative paths (both files and synthesised directories, the
+ * latter with a trailing slash). Returns true to drop the entry.
+ */
+ fileFilter?: (cwdRelative: string) => boolean;
+ onProgress?: (chunk: string[]) => void;
+ progressChunkSize?: number; // default 2000
+ progressFlushMs?: number; // default 50
+ /** Abort signal propagated to the rg child process. */
+ signal?: AbortSignal;
+}
+
+export interface RipgrepCrawlResult {
+ files: string[];
+ /** True if we hit `maxFiles` and truncated. */
+ truncated: boolean;
+}
+
+/**
+ * Lightweight guard: many shell metacharacters in user-supplied ignoreDir
+ * strings would be safely passed to rg as arguments (execFile-style spawn),
+ * but a dir name containing a NUL byte or a newline would confuse the NUL
+ * splitter. Callers should normally pass clean relative dir names, so we
+ * just pick those out here.
+ */
+function sanitiseExcludeDir(dir: string): string | null {
+ if (!dir || dir.includes('\0') || dir.includes('\n')) return null;
+ // Strip leading/trailing slashes for glob consistency.
+ return dir.replace(/^\/+|\/+$/g, '');
+}
+
+function toPosixPath(p: string): string {
+ return p.split(path.sep).join(path.posix.sep);
+}
+
+/**
+ * Lists files under `crawlDirectory` via bundled ripgrep. Much faster than
+ * the fdir fallback on large trees (100k files in <200ms on typical
+ * workstations) because rg's Rust walker is parallel and it reuses the
+ * same .gitignore parser it uses for content search. Falls back to the
+ * caller's fdir path on failure — see crawler.ts.
+ *
+ * Semantic contract:
+ * - Returned paths are POSIX-style, relative to `cwd` (not `crawlDirectory`).
+ * - Both files and the directories containing them are returned. Directory
+ * entries carry a trailing slash, matching the pre-existing fdir output
+ * so tests and consumers don't need to distinguish code paths.
+ * - `.gitignore`, `.ignore`, and (rg-internal) global ignore files are
+ * honoured automatically; `.qwenignore` must be applied via `fileFilter`.
+ * - rg's own `.git/` auto-skip is defensive, but we also pass `--glob
+ * '!.git'` because `--hidden` would otherwise reveal `.git/`. Same for
+ * any caller-supplied `extraExcludeDirs`.
+ */
+export async function ripgrepCrawl(
+ options: RipgrepCrawlOptions,
+): Promise {
+ const selection = await resolveRipgrep();
+ if (!selection) {
+ throw new Error('ripgrep binary not available');
+ }
+
+ const chunkSize = options.progressChunkSize ?? 2000;
+ const flushMs = options.progressFlushMs ?? 50;
+ const maxFiles = options.maxFiles ?? Infinity;
+
+ const args = [
+ '--files',
+ // Include dotfiles/dotdirs (except ones matched by the excludes below).
+ '--hidden',
+ // Allow rg to respect .gitignore even outside a git checkout (rg 13+).
+ '--no-require-git',
+ // Skip rg's parent-directory ignore lookup — we operate in the project
+ // root by contract, and letting rg walk up to HOME produces surprising
+ // results (user's global .gitignore becomes effective).
+ '--no-ignore-parent',
+ // NUL-separated output is safe for paths containing newlines or other
+ // special characters; we split on \0 below.
+ '-0',
+ '--glob',
+ '!.git',
+ ];
+ for (const dir of options.extraExcludeDirs ?? []) {
+ const cleaned = sanitiseExcludeDir(dir);
+ if (cleaned === null || cleaned === '') continue;
+ args.push('--glob', `!${cleaned}`);
+ }
+ // Pass `.` as the path and set cwd=crawlDirectory in the spawn options.
+ // rg reflects its input path back in the output, so passing an absolute
+ // path here would yield absolute paths from stdout — which would break
+ // the ignore-lib post-filter (it requires relative paths).
+ args.push('.');
+
+ const posixCwd = toPosixPath(options.cwd);
+ const posixCrawlDirectory = toPosixPath(options.crawlDirectory);
+ const relativeToCrawlDir = path.posix.relative(posixCwd, posixCrawlDirectory);
+ const fileFilter = options.fileFilter;
+
+ // Seed with `.` for parity with fdir's `.withDirs()` output — downstream
+ // consumers skip it in their own filter loop but some callers (and the
+ // pre-existing crawler tests) assert its presence.
+ const files: string[] = ['.'];
+ const dirSet = new Set();
+ let truncated = false;
+ let progressBuffer: string[] = options.onProgress ? ['.'] : [];
+ let lastFlushAt = Date.now();
+ const flushProgress = () => {
+ if (!options.onProgress || progressBuffer.length === 0) return;
+ const toSend = progressBuffer;
+ progressBuffer = [];
+ lastFlushAt = Date.now();
+ try {
+ options.onProgress(toSend);
+ } catch {
+ // best-effort
+ }
+ };
+ const pushAllowed = (value: string): boolean => {
+ // Guard: both the file and every synthesised directory consume one
+ // slot of the maxFiles budget, so we check before each individual push.
+ // Returning false here signals the caller to stop streaming further
+ // entries from rg.
+ if (files.length >= maxFiles) {
+ truncated = true;
+ return false;
+ }
+ files.push(value);
+ if (options.onProgress) progressBuffer.push(value);
+ return true;
+ };
+
+ const recordPath = (p: string): boolean => {
+ if (files.length >= maxFiles) {
+ truncated = true;
+ return false;
+ }
+ if (fileFilter && fileFilter(p)) return true; // filtered but keep going
+ if (!pushAllowed(p)) return false;
+ // Synthesise directory entries lazily so the output shape matches the
+ // previous fdir-based crawl (`getFolderStructure` etc. expect `foo/`
+ // entries). Only emit each unique directory once.
+ let dirEnd = p.lastIndexOf('/');
+ while (dirEnd > 0) {
+ const dir = `${p.slice(0, dirEnd)}/`;
+ if (dirSet.has(dir)) break;
+ if (fileFilter && fileFilter(dir)) {
+ dirSet.add(dir); // prevent re-checking filtered-out dirs
+ break;
+ }
+ dirSet.add(dir);
+ if (!pushAllowed(dir)) return false;
+ dirEnd = p.lastIndexOf('/', dirEnd - 1);
+ }
+ if (
+ options.onProgress &&
+ (progressBuffer.length >= chunkSize ||
+ Date.now() - lastFlushAt >= flushMs)
+ ) {
+ flushProgress();
+ }
+ return true;
+ };
+
+ await new Promise((resolve, reject) => {
+ const child = spawn(selection.command, args, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ signal: options.signal,
+ cwd: options.crawlDirectory,
+ });
+
+ let stdoutBuf = '';
+ let stderrBuf = '';
+ let aborted = false;
+
+ child.stdout.setEncoding('utf8');
+ child.stdout.on('data', (data: string) => {
+ if (aborted) return;
+ stdoutBuf += data;
+ // Split on NUL; last segment may be partial and rejoins the buffer.
+ let idx = stdoutBuf.indexOf('\0');
+ while (idx !== -1) {
+ const raw = stdoutBuf.slice(0, idx);
+ stdoutBuf = stdoutBuf.slice(idx + 1);
+ if (raw.length > 0) {
+ // rg emits paths relative to the invocation dir with a leading
+ // `./` on POSIX or `.\` on Windows. Normalize to posix separators
+ // FIRST so the prefix check catches both; otherwise Windows paths
+ // keep the `./` prefix after toPosixPath, and downstream
+ // buildRipgrepFileFilter then asks `ignore` to test `"./"`, which
+ // throws RangeError and poisons the stdout stream.
+ let p = toPosixPath(raw);
+ if (p.startsWith('./')) p = p.slice(2);
+ if (relativeToCrawlDir) {
+ p = path.posix.join(relativeToCrawlDir, p);
+ }
+ if (!recordPath(p)) {
+ aborted = true;
+ child.kill('SIGTERM');
+ break;
+ }
+ }
+ idx = stdoutBuf.indexOf('\0');
+ }
+ });
+
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (data: string) => {
+ stderrBuf += data;
+ });
+
+ child.on('error', (err) => reject(err));
+ child.on('close', (code, signal) => {
+ flushProgress();
+ // stdin-open-but-closed code 0: fine. code 1: no matches (fine for --files).
+ // code 2: usage error. We also accept SIGTERM when we deliberately killed it.
+ if (aborted) return resolve();
+ if (code === 0 || code === 1) return resolve();
+ if (signal === 'SIGTERM' && options.signal?.aborted) return resolve();
+ reject(
+ new Error(
+ `ripgrep exited with code=${code ?? 'null'} signal=${
+ signal ?? 'null'
+ }${stderrBuf ? `: ${stderrBuf.trim().split('\n')[0]}` : ''}`,
+ ),
+ );
+ });
+ });
+
+ // ripgrep only lists files, so genuinely empty directories — or
+ // directories whose entire contents are filtered away — never show up
+ // in the stream. fdir's legacy output did include them via
+ // `.withDirs()`, and callers (the @-picker, tests) assume the tree
+ // structure is fully represented. Fill the gap with a directory-only
+ // pass: fs.readdir is fast when we skip files, so even on large trees
+ // this is a few tens of milliseconds. Any dir already synthesised from
+ // a file path is deduped via `dirSet`.
+ await enumerateEmptyDirs(
+ options.crawlDirectory,
+ relativeToCrawlDir,
+ options.fileFilter,
+ maxFiles,
+ dirSet,
+ (dir) => {
+ if (files.length >= maxFiles) {
+ truncated = true;
+ return false;
+ }
+ files.push(dir);
+ if (options.onProgress) progressBuffer.push(dir);
+ return true;
+ },
+ );
+ flushProgress();
+
+ // Sort in breadth-first order to match fdir's default traversal shape.
+ // Downstream fzf ranking breaks score ties using list position, so the
+ // @-picker's suggestion order is stable only if we feed fzf a
+ // deterministic, natural-feeling order. BFS puts `.` first, then
+ // top-level entries alphabetically, then one-deep, and so on — the same
+ // order a user would expect to see in a tree view.
+ const depth = (p: string): number => {
+ if (p === '.') return 0;
+ let slashes = 0;
+ for (let i = 0; i < p.length; i++) if (p[i] === '/') slashes++;
+ return p.endsWith('/') ? slashes - 1 : slashes;
+ };
+ files.sort((a, b) => {
+ if (a === '.' && b !== '.') return -1;
+ if (b === '.' && a !== '.') return 1;
+ const da = depth(a);
+ const db = depth(b);
+ if (da !== db) return da - db;
+ return a < b ? -1 : a > b ? 1 : 0;
+ });
+
+ return { files, truncated };
+}
+
+/**
+ * Walk the project tree with `fs.readdir` (dirs only, files skipped) to
+ * capture directories that ripgrep didn't emit because they contain no
+ * files. Honours the same `fileFilter` semantics as the main crawl so that
+ * e.g. `.git`, `node_modules`, or anything covered by `.qwenignore` stays
+ * excluded. Cheap in practice — a filesystem tree has orders of magnitude
+ * fewer directories than files.
+ */
+async function enumerateEmptyDirs(
+ crawlDirectory: string,
+ relativeToCrawlDir: string,
+ fileFilter: ((cwdRelative: string) => boolean) | undefined,
+ maxFiles: number,
+ dirSet: Set,
+ emit: (dir: string) => boolean,
+): Promise {
+ const visit = async (absDir: string, relDir: string): Promise => {
+ let entries: Array;
+ try {
+ entries = await fs.readdir(absDir, { withFileTypes: true });
+ } catch {
+ return true; // unreadable dir; skip silently
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const childRel = relDir ? `${relDir}/${entry.name}` : entry.name;
+ const cwdRelative = relativeToCrawlDir
+ ? path.posix.join(relativeToCrawlDir, childRel)
+ : childRel;
+ const dirPath = `${cwdRelative}/`;
+ // Prune like the main crawl does: rg already skipped .git etc., but
+ // because we walk independently here we must re-apply the ignore
+ // rules to match semantics.
+ if (fileFilter && fileFilter(dirPath)) continue;
+ if (!dirSet.has(dirPath)) {
+ dirSet.add(dirPath);
+ if (!emit(dirPath)) return false;
+ }
+ const absChild = path.join(absDir, entry.name);
+ if (!(await visit(absChild, childRel))) return false;
+ if (dirSet.size + 0 >= maxFiles) {
+ // Defensive: the emit callback enforces maxFiles too.
+ break;
+ }
+ }
+ return true;
+ };
+ await visit(crawlDirectory, '');
+}
+
+/**
+ * Adapter that accepts the same `Ignore` instance the fdir crawler uses and
+ * returns a `fileFilter` suitable for `ripgrepCrawl`. Encapsulates the
+ * handful of semantic differences:
+ *
+ * 1. `.qwenignore` is not part of the gitignore family rg reads, so we
+ * apply it via the post-filter.
+ * 2. `fdir`'s `.withDirs()` also emits the crawl root as `'.'`; rg doesn't.
+ * Callers strip `.` at the consumer layer (see `FileIndexCore.search`).
+ */
+export function buildRipgrepFileFilter(
+ ignore: Ignore,
+): (cwdRelative: string) => boolean {
+ const fileIgnore = ignore.getFileFilter();
+ const dirIgnore = ignore.getDirectoryFilter();
+ return (p: string) => {
+ if (p === '' || p === '.' || p === './') return true;
+ // Defensive: a leading "./" would make the ancestor-dir walk below call
+ // `dirIgnore("./")`, which the ignore lib rejects with a RangeError.
+ // Callers strip this already; the guard is cheap insurance.
+ if (p.startsWith('./')) p = p.slice(2);
+ // Directory entry (trailing slash) — consult the dir filter directly.
+ if (p.endsWith('/')) {
+ return dirIgnore(p);
+ }
+ // Walk ancestor directories: rg only honours .gitignore / .ignore, so a
+ // .qwenignore rule like `dist/` (a directory pattern, stored only in the
+ // dirIgnorer) must be enforced here by checking each parent directory.
+ // Without this, `dist/ignored.js` slips through because `fileIgnore`
+ // doesn't know about directory-only patterns.
+ let slash = p.indexOf('/');
+ while (slash !== -1) {
+ if (dirIgnore(`${p.slice(0, slash)}/`)) return true;
+ slash = p.indexOf('/', slash + 1);
+ }
+ return fileIgnore(p);
+ };
+}
diff --git a/packages/core/test-setup.ts b/packages/core/test-setup.ts
index df4d79a0461..8aef0746963 100644
--- a/packages/core/test-setup.ts
+++ b/packages/core/test-setup.ts
@@ -30,3 +30,12 @@ if (process.env['QWEN_CODE_MEMORY_LOCAL'] === undefined) {
if (typeof (globalThis as unknown as { File?: unknown }).File === 'undefined') {
(globalThis as unknown as { File: unknown }).File = class {} as unknown;
}
+
+// Note on FileIndexService: the default transport spawns a real Node worker
+// thread loading the compiled `fileIndexWorker.js`, which vitest can't use
+// when executing TS sources directly. Tests that exercise FileIndexService
+// must opt in to the in-process transport via a local `beforeAll` —
+// installing it here would eagerly pull `src/index.ts` (and thus
+// `workspaceContext.ts` with a real `node:fs` binding) into every test
+// file's module graph, breaking tests that rely on `vi.mock('fs', …)`
+// (e.g. `packages/cli/src/config/config.test.ts` bare-mode cases).
diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts
index d6ff4c4a9f5..9e37379cec9 100644
--- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts
+++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts
@@ -14,6 +14,13 @@ const shouldIgnoreFileMock = vi.hoisted(() => vi.fn());
const fileSearchMock = vi.hoisted(() => ({
initialize: vi.fn(),
search: vi.fn(),
+ dispose: vi.fn(),
+}));
+const crawlCacheClearMock = vi.hoisted(() => vi.fn());
+
+const watcherCallbacks = vi.hoisted(() => ({
+ onDidCreate: [] as Array<() => void>,
+ onDidDelete: [] as Array<() => void>,
}));
const vscodeMock = vi.hoisted(() => {
@@ -30,16 +37,30 @@ const vscodeMock = vi.hoisted(() => {
}
}
+ class RelativePattern {
+ base: unknown;
+ pattern: string;
+ constructor(base: unknown, pattern: string) {
+ this.base = base;
+ this.pattern = pattern;
+ }
+ }
+
return {
Uri,
+ RelativePattern,
workspace: {
findFiles: vi.fn(),
getWorkspaceFolder: vi.fn(),
asRelativePath: vi.fn(),
workspaceFolders: [] as vscode.WorkspaceFolder[],
createFileSystemWatcher: vi.fn(() => ({
- onDidCreate: vi.fn(),
- onDidDelete: vi.fn(),
+ onDidCreate: vi.fn((cb: () => void) =>
+ watcherCallbacks.onDidCreate.push(cb),
+ ),
+ onDidDelete: vi.fn((cb: () => void) =>
+ watcherCallbacks.onDidDelete.push(cb),
+ ),
onDidChange: vi.fn(),
dispose: vi.fn(),
})),
@@ -71,12 +92,14 @@ vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/fileSearch.js', () => ({
},
}));
vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/crawlCache.js', () => ({
- clear: vi.fn(),
+ clear: crawlCacheClearMock,
}));
describe('FileMessageHandler', () => {
beforeEach(() => {
vi.clearAllMocks();
+ watcherCallbacks.onDidCreate.length = 0;
+ watcherCallbacks.onDidDelete.length = 0;
});
it('searches files using fuzzy search when query is provided', async () => {
@@ -183,4 +206,67 @@ describe('FileMessageHandler', () => {
expect(payload.type).toBe('workspaceFiles');
expect(payload.data.requestId).toBe(7);
});
+
+ it('disposes stale index when clearFileSearchCache fires during in-flight initialize()', async () => {
+ // Regression: the cache-invalidation path previously left the in-flight
+ // `initialize()` alive. When that promise resolved after
+ // clearFileSearchCache, the finally-path still stored the (now stale)
+ // search under the same rootPath, and a subsequent getWorkspaceFiles
+ // call would happily search against the outdated index.
+ const rootPath = '/workspace';
+ vscodeMock.workspace.workspaceFolders = [
+ { uri: vscode.Uri.file(rootPath), name: 'workspace', index: 0 },
+ ];
+
+ // Deferred initialize: the race only matters while initialize() is
+ // pending. Resolving it by hand lets the test interleave the watcher
+ // callback deterministically.
+ let resolveInit!: () => void;
+ fileSearchMock.initialize.mockImplementation(
+ () =>
+ new Promise((res) => {
+ resolveInit = res;
+ }),
+ );
+ fileSearchMock.search.mockResolvedValue([]);
+
+ const sendToWebView = vi.fn();
+ const handler = new FileMessageHandler(
+ {} as QwenAgentManager,
+ {} as ConversationStore,
+ null,
+ sendToWebView,
+ );
+ // Attach watchers so the clear callback exists.
+ handler.setupFileWatchers();
+ expect(watcherCallbacks.onDidCreate.length).toBeGreaterThan(0);
+
+ // Kick off a query that triggers getOrCreateFileSearch; awaiting this
+ // promise deadlocks until initialize() resolves, so we must invalidate
+ // the cache mid-flight before releasing init.
+ const inflight = handler.handle({
+ type: 'getWorkspaceFiles',
+ data: { query: 'foo', requestId: 1 },
+ });
+
+ // Let the handler schedule its await initialize() before we invalidate.
+ await Promise.resolve();
+
+ // File-system watcher fires — clearFileSearchCache removes the entry
+ // from fileSearchInitializing while the init is still pending.
+ for (const cb of watcherCallbacks.onDidCreate) {
+ cb();
+ }
+
+ // Now let initialize() resolve; the race-guard inside the init promise
+ // should detect the invalidation and dispose the search rather than
+ // re-caching it.
+ resolveInit();
+ await inflight;
+
+ expect(fileSearchMock.dispose).toHaveBeenCalled();
+ // The stale search must never have been asked to run the query —
+ // getOrCreateFileSearch returned null and the caller skipped.
+ expect(fileSearchMock.search).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts
index f8708d8d4d3..44882163562 100644
--- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts
+++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts
@@ -32,21 +32,11 @@ export class FileMessageHandler extends BaseMessageHandler {
>();
private readonly fileSearchInstances = new Map();
private readonly fileSearchInitializing = new Map>();
+ // Per-rootPath generation token. Incremented whenever clearFileSearchCache
+ // fires, so an in-flight initialize() can detect it raced against a cache
+ // invalidation and skip re-caching the stale index.
+ private readonly fileSearchInitTokens = new Map();
private readonly fileWatchers = new Map();
- private readonly globSpecialChars = new Set([
- '\\',
- '*',
- '?',
- '[',
- ']',
- '{',
- '}',
- '(',
- ')',
- '!',
- '+',
- '@',
- ]);
canHandle(messageType: string): boolean {
return [
@@ -73,6 +63,13 @@ export class FileMessageHandler extends BaseMessageHandler {
return this.fileSearchInstances.get(rootPath) ?? null;
}
+ // Mint a generation token before kicking off initialize(). Race guard
+ // below compares against this token after the async build completes —
+ // `clearFileSearchCache` deletes the map entry, so a mid-flight
+ // invalidation flips the identity and we dispose rather than cache.
+ const token = Symbol('fileSearchInit');
+ this.fileSearchInitTokens.set(rootPath, token);
+
const initPromise = (async () => {
const search = FileSearchFactory.create({
projectRoot: rootPath,
@@ -85,6 +82,14 @@ export class FileMessageHandler extends BaseMessageHandler {
enableFuzzySearch: true,
});
await search.initialize();
+ if (this.fileSearchInitTokens.get(rootPath) !== token) {
+ // clearFileSearchCache fired while we were crawling; the index we
+ // just built reflects a stale view of the filesystem. Dispose it
+ // rather than re-caching under the same rootPath, which would
+ // masquerade as fresh. Next getOrCreateFileSearch call starts new.
+ void search.dispose?.();
+ return;
+ }
this.fileSearchInstances.set(rootPath, search);
})();
@@ -104,9 +109,21 @@ export class FileMessageHandler extends BaseMessageHandler {
}
private clearFileSearchCache(rootPath: string): void {
+ const existing = this.fileSearchInstances.get(rootPath);
this.fileSearchInstances.delete(rootPath);
this.fileSearchInitializing.delete(rootPath);
+ // Invalidate any in-flight initialize() so it disposes instead of
+ // writing its (now stale) index into fileSearchInstances when it lands.
+ this.fileSearchInitTokens.delete(rootPath);
+ // Drop the in-process crawl cache and, crucially, dispose the
+ // worker-backed FileIndexService singleton so its in-memory snapshot
+ // and fzf index are rebuilt from disk on the next search. Without
+ // dispose(), the worker keeps serving stale results until the process
+ // exits (FileIndexService.for() memoises by optionsKey).
crawlCache.clear();
+ void existing?.dispose?.().catch((err) => {
+ console.warn('[FileMessageHandler] FileSearch dispose failed:', err);
+ });
console.log(
'[FileMessageHandler] Cleared file search cache, trigger:',
rootPath,
@@ -189,13 +206,13 @@ export class FileMessageHandler extends BaseMessageHandler {
case 'getWorkspaceFiles':
await this.handleGetWorkspaceFiles(
- data?.query as string | undefined,
- data?.requestId as number | undefined,
+ data?.['query'] as string | undefined,
+ data?.['requestId'] as number | undefined,
);
break;
case 'openFile':
- await this.handleOpenFile(data?.path as string | undefined);
+ await this.handleOpenFile(data?.['path'] as string | undefined);
break;
case 'openDiff':
@@ -592,9 +609,9 @@ export class FileMessageHandler extends BaseMessageHandler {
try {
await vscode.commands.executeCommand(showDiffCommand, {
- path: (data.path as string) || '',
- oldText: (data.oldText as string) || '',
- newText: (data.newText as string) || '',
+ path: (data['path'] as string) || '',
+ oldText: (data['oldText'] as string) || '',
+ newText: (data['newText'] as string) || '',
});
} catch (error) {
console.error('[FileMessageHandler] Failed to open diff:', error);
@@ -618,8 +635,8 @@ export class FileMessageHandler extends BaseMessageHandler {
}
try {
- const content = (data.content as string) || '';
- const fileName = (data.fileName as string) || 'temp';
+ const content = (data['content'] as string) || '';
+ const fileName = (data['fileName'] as string) || 'temp';
// Get readonly file system provider from global singleton
const readonlyProvider = ReadonlyFileSystemProvider.getInstance();
@@ -703,18 +720,4 @@ export class FileMessageHandler extends BaseMessageHandler {
);
}
}
-
- private buildCaseInsensitiveGlob(query: string): string {
- let pattern = '';
- for (const char of query) {
- if (/[a-zA-Z]/.test(char)) {
- pattern += `[${char.toLowerCase()}${char.toUpperCase()}]`;
- } else if (this.globSpecialChars.has(char)) {
- pattern += `\\${char}`;
- } else {
- pattern += char;
- }
- }
- return pattern;
- }
}
diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js
index 28811c0fbfe..3935f05b53e 100644
--- a/scripts/prepare-package.js
+++ b/scripts/prepare-package.js
@@ -159,6 +159,11 @@ const distPackageJson = {
},
files: [
'cli.js',
+ // Worker thread entry loaded by fileIndexService at runtime via
+ // `new URL('./fileIndexWorker.js', import.meta.url)`. Must ship in the
+ // tarball or the @-picker crashes on the first search in an npm-installed
+ // CLI.
+ 'fileIndexWorker.js',
'vendor',
'*.sb',
'README.md',