mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
v0.1.104-b
...
dotfile-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c02b9ec7c | ||
|
|
9905b0da31 |
12
.github/workflows/desktop-release.yml
vendored
12
.github/workflows/desktop-release.yml
vendored
@@ -329,6 +329,18 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Windows arm64 native search packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
|
||||
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
|
||||
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
|
||||
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -169,6 +169,14 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
### Workspace search
|
||||
|
||||
Workspace file and directory suggestions are backed by `@ff-labs/fff-node` in
|
||||
`packages/server/src/server/search/workspace-entries.ts`. Treat that backend as
|
||||
the search and ignore boundary: do not add a separate `.gitignore`/`.rgignore`
|
||||
matcher in Paseo. If ignore semantics need to change, change the backend
|
||||
contract and keep the packaged desktop smoke covering dot-prefixed suggestions.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
|
||||
332
package-lock.json
generated
332
package-lock.json
generated
@@ -6154,6 +6154,141 @@
|
||||
"excpretty": "build/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-darwin-arm64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-arm64/-/fff-bin-darwin-arm64-0.9.6.tgz",
|
||||
"integrity": "sha512-gJzvYuAbudAIvUVJAAMXjManTPxmFqqqqbJSZ0RuZTNWLK2nFMKMo0XUVCXcRVaTENY/VRoHjTTFCHjVebmolw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-darwin-x64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-darwin-x64/-/fff-bin-darwin-x64-0.9.6.tgz",
|
||||
"integrity": "sha512-m+p7te06G785VS6fLCFEEeO0twZqmWxy3zzCxcICdwebFqDY3cQGGqDQ6Lmr264Q36bLQjLE7t2iqWIwRoI6dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-arm64-gnu": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-gnu/-/fff-bin-linux-arm64-gnu-0.9.6.tgz",
|
||||
"integrity": "sha512-UjqXuvJc2z2yeQ/6RqkgL1skdbzoZyexOvsBAt1VDIjUnm5AqoUoqh/guRQ2mke0dBWktZYRtvCrdS4CHB7dfA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-arm64-musl": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-arm64-musl/-/fff-bin-linux-arm64-musl-0.9.6.tgz",
|
||||
"integrity": "sha512-OdERLjrCwx3Ykd0szEe2WxDZ44653ZHftIl5XV4QFnsyn6jLaxu227JV3Ap9BIG3VBrKIBtr5pUqk9Tumbrolg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-x64-gnu": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-gnu/-/fff-bin-linux-x64-gnu-0.9.6.tgz",
|
||||
"integrity": "sha512-OdROw237Ne7qy3HbTB78eovQMusccJ0PhRNAB6AmFuo+MiAzi1AdYs9iTAPYHqEH3uwEu0fdVFo9GTZcdnm03g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-linux-x64-musl": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-linux-x64-musl/-/fff-bin-linux-x64-musl-0.9.6.tgz",
|
||||
"integrity": "sha512-/7GJq5sw2WaNU79OKQqymYAF7lceMUm0OBw61M6tMDGi40LG1wNSF7uf8uLkD/p6GNkx8r/knmMDUuAKp208CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-win32-arm64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-arm64/-/fff-bin-win32-arm64-0.9.6.tgz",
|
||||
"integrity": "sha512-o0e8qu0kwqWEoV8rn/0wEmlFYBY09xZwaNdmBoGU6zXWZAviP/V92fzEED/yx0iIshq+qAYsuTuQSOj7gVDbVg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-bin-win32-x64": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-bin-win32-x64/-/fff-bin-win32-x64-0.9.6.tgz",
|
||||
"integrity": "sha512-fkD3e6FmfR3i5Ymv14gfPT1FeJpnAXwe9Fu0r4sSDhvSE0x0ADQKEvwjMUBosb02O8XB9wWURx9cov1rCHVTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ff-labs/fff-node": {
|
||||
"version": "0.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@ff-labs/fff-node/-/fff-node-0.9.6.tgz",
|
||||
"integrity": "sha512-2LbHJjwW/7vOLh1lSv0ipzA9Du5+LatUGiumQFaO9IcjMT8YgpmrCp/RRMyInsG0iBIX6A0WSxgei8qOFJLlNw==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"ffi-rs": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ff-labs/fff-bin-darwin-arm64": "0.9.6",
|
||||
"@ff-labs/fff-bin-darwin-x64": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-arm64-gnu": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-arm64-musl": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-x64-gnu": "0.9.6",
|
||||
"@ff-labs/fff-bin-linux-x64-musl": "0.9.6",
|
||||
"@ff-labs/fff-bin-win32-arm64": "0.9.6",
|
||||
"@ff-labs/fff-bin-win32-x64": "0.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
@@ -13761,6 +13896,183 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-android-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-android-arm64/-/ffi-rs-android-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-arm64/-/ffi-rs-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-darwin-x64/-/ffi-rs-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm-gnueabihf/-/ffi-rs-linux-arm-gnueabihf-1.3.2.tgz",
|
||||
"integrity": "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-arm64-gnu": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-gnu/-/ffi-rs-linux-arm64-gnu-1.3.2.tgz",
|
||||
"integrity": "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-arm64-musl": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-arm64-musl/-/ffi-rs-linux-arm64-musl-1.3.2.tgz",
|
||||
"integrity": "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-x64-gnu": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-gnu/-/ffi-rs-linux-x64-gnu-1.3.2.tgz",
|
||||
"integrity": "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-linux-x64-musl": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-linux-x64-musl/-/ffi-rs-linux-x64-musl-1.3.2.tgz",
|
||||
"integrity": "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-win32-arm64-msvc": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-arm64-msvc/-/ffi-rs-win32-arm64-msvc-1.3.2.tgz",
|
||||
"integrity": "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-win32-ia32-msvc": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-ia32-msvc/-/ffi-rs-win32-ia32-msvc-1.3.2.tgz",
|
||||
"integrity": "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w==",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@yuuang/ffi-rs-win32-x64-msvc": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@yuuang/ffi-rs-win32-x64-msvc/-/ffi-rs-win32-x64-msvc-1.3.2.tgz",
|
||||
"integrity": "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
@@ -21209,6 +21521,25 @@
|
||||
"integrity": "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ffi-rs": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ffi-rs/-/ffi-rs-1.3.2.tgz",
|
||||
"integrity": "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@yuuang/ffi-rs-android-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
|
||||
"@yuuang/ffi-rs-darwin-x64": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-arm64-musl": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
|
||||
"@yuuang/ffi-rs-linux-x64-musl": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2",
|
||||
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -38021,6 +38352,7 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@ff-labs/fff-node": "^0.9.6",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/highlight": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import {
|
||||
@@ -209,6 +211,25 @@ async function openReadyMockAgent(
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDotPrefixedWorkspaceFiles(cwd: string): Promise<void> {
|
||||
await writeFile(path.join(cwd, ".env.local"), "PASEO_E2E=1\n");
|
||||
await mkdir(path.join(cwd, ".opencode"), { recursive: true });
|
||||
await writeFile(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
||||
}
|
||||
|
||||
async function expectFileMentionSuggestion(
|
||||
page: Page,
|
||||
query: string,
|
||||
suggestion: string,
|
||||
): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await input.fill(query);
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText(suggestion, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function visiblePopoverBox(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
@@ -519,6 +540,28 @@ test.describe("Composer autocomplete", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("suggests dot-prefixed workspace entries for file mentions", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-dot-entries-",
|
||||
title: "Dot file mention autocomplete",
|
||||
});
|
||||
|
||||
try {
|
||||
await seedDotPrefixedWorkspaceFiles(session.cwd);
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await expectFileMentionSuggestion(page, "@.env", ".env.local");
|
||||
await expectFileMentionSuggestion(page, "@.opencode", ".opencode/settings.json");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
@@ -16,6 +16,10 @@ files:
|
||||
asarUnpack:
|
||||
- dist/daemon/node-entrypoint-runner.js
|
||||
- node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/*
|
||||
- node_modules/@ff-labs/fff-node/**/*
|
||||
- node_modules/@ff-labs/fff-bin-*/**/*
|
||||
- node_modules/ffi-rs/**/*
|
||||
- node_modules/@yuuang/ffi-rs-*/**/*
|
||||
extraResources:
|
||||
- from: ../app/dist
|
||||
to: app-dist
|
||||
|
||||
@@ -14,6 +14,38 @@ const RIPGREP_PLATFORM_DIR = {
|
||||
win32: { arm64: "arm64-win32", x64: "x64-win32" },
|
||||
};
|
||||
|
||||
const FFF_BIN_PACKAGE = {
|
||||
darwin: {
|
||||
arm64: ["fff-bin-darwin-arm64"],
|
||||
x64: ["fff-bin-darwin-x64"],
|
||||
universal: ["fff-bin-darwin-arm64", "fff-bin-darwin-x64"],
|
||||
},
|
||||
linux: {
|
||||
arm64: ["fff-bin-linux-arm64-gnu"],
|
||||
x64: ["fff-bin-linux-x64-gnu"],
|
||||
},
|
||||
win32: {
|
||||
arm64: ["fff-bin-win32-arm64"],
|
||||
x64: ["fff-bin-win32-x64"],
|
||||
},
|
||||
};
|
||||
|
||||
const FFI_RS_PACKAGE = {
|
||||
darwin: {
|
||||
arm64: ["ffi-rs-darwin-arm64"],
|
||||
x64: ["ffi-rs-darwin-x64"],
|
||||
universal: ["ffi-rs-darwin-arm64", "ffi-rs-darwin-x64"],
|
||||
},
|
||||
linux: {
|
||||
arm64: ["ffi-rs-linux-arm64-gnu"],
|
||||
x64: ["ffi-rs-linux-x64-gnu"],
|
||||
},
|
||||
win32: {
|
||||
arm64: ["ffi-rs-win32-arm64-msvc"],
|
||||
x64: ["ffi-rs-win32-x64-msvc"],
|
||||
},
|
||||
};
|
||||
|
||||
function rmSafe(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
@@ -73,6 +105,43 @@ function pruneSharpLibvips(nodeModules, platform, arch) {
|
||||
}
|
||||
}
|
||||
|
||||
function keepPackagesForPlatform(packagesByPlatform, platform, arch) {
|
||||
return packagesByPlatform[platform]?.[arch] ?? [];
|
||||
}
|
||||
|
||||
function pruneScopedNativePackages(nodeModules, scope, prefix, keepNames) {
|
||||
if (keepNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeDir = path.join(nodeModules, scope);
|
||||
if (!fs.existsSync(scopeDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keep = new Set(keepNames);
|
||||
for (const entry of fs.readdirSync(scopeDir)) {
|
||||
if (entry.startsWith(prefix) && !keep.has(entry)) {
|
||||
rmSafe(path.join(scopeDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneFffNativePackages(nodeModules, platform, arch) {
|
||||
pruneScopedNativePackages(
|
||||
nodeModules,
|
||||
"@ff-labs",
|
||||
"fff-bin-",
|
||||
keepPackagesForPlatform(FFF_BIN_PACKAGE, platform, arch),
|
||||
);
|
||||
pruneScopedNativePackages(
|
||||
nodeModules,
|
||||
"@yuuang",
|
||||
"ffi-rs-",
|
||||
keepPackagesForPlatform(FFI_RS_PACKAGE, platform, arch),
|
||||
);
|
||||
}
|
||||
|
||||
function pruneNativeModules(appOutDir, platform, arch) {
|
||||
const resourcesDir =
|
||||
platform === "darwin"
|
||||
@@ -87,6 +156,7 @@ function pruneNativeModules(appOutDir, platform, arch) {
|
||||
pruneClaudeAgentSdk(nodeModules, platform, arch);
|
||||
pruneNodePty(nodeModules, platform, arch);
|
||||
pruneSharpLibvips(nodeModules, platform, arch);
|
||||
pruneFffNativePackages(nodeModules, platform, arch);
|
||||
|
||||
const after = dirSizeSync(nodeModules);
|
||||
const savedMB = ((before - after) / 1024 / 1024).toFixed(1);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const { execFileSync, spawn, spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const SMOKE_TIMEOUT_MS = 60_000;
|
||||
@@ -621,6 +622,97 @@ async function smokeCliTerminal({ appPath, env }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnectToDaemon() {
|
||||
const clientModulePath = path.join(__dirname, "..", "..", "cli", "dist", "utils", "client.js");
|
||||
const clientModule = await import(pathToFileURL(clientModulePath).href);
|
||||
if (typeof clientModule.connectToDaemon !== "function") {
|
||||
throw new Error(`CLI client module did not export connectToDaemon: ${clientModulePath}`);
|
||||
}
|
||||
return clientModule.connectToDaemon;
|
||||
}
|
||||
|
||||
function createWorkspaceSearchFixture() {
|
||||
const cwd = createTempDir("paseo-smoke-search-cwd-");
|
||||
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(cwd, ".env.local"), "PASEO_PACKAGED_SMOKE=1\n");
|
||||
fs.mkdirSync(path.join(cwd, ".opencode"), { recursive: true });
|
||||
fs.writeFileSync(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(cwd, ".gitignore"), "ignored.secret\n");
|
||||
fs.writeFileSync(path.join(cwd, "ignored.secret"), "ignore me\n");
|
||||
return cwd;
|
||||
}
|
||||
|
||||
function assertSuggestionPayload(payload, label) {
|
||||
if (payload?.error) {
|
||||
throw new Error(`${label} returned error: ${payload.error}`);
|
||||
}
|
||||
if (!Array.isArray(payload?.entries)) {
|
||||
throw new Error(`${label} returned invalid payload: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSuggestionEntry(payload, expected, label) {
|
||||
assertSuggestionPayload(payload, label);
|
||||
const found = payload.entries.some(
|
||||
(entry) => entry.path === expected.path && entry.kind === expected.kind,
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`${label} did not include ${expected.kind} ${expected.path}. Entries: ${JSON.stringify(
|
||||
payload.entries,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeWorkspaceSearch({ host }) {
|
||||
console.log("Packaged desktop smoke: querying workspace search through daemon RPC");
|
||||
const cwd = createWorkspaceSearchFixture();
|
||||
const connectToDaemon = await loadConnectToDaemon();
|
||||
const client = await connectToDaemon({ host, timeout: SMOKE_TIMEOUT_MS });
|
||||
|
||||
try {
|
||||
const envResults = await client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "env",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
});
|
||||
assertSuggestionEntry(envResults, { path: ".env.local", kind: "file" }, "Dotfile search");
|
||||
|
||||
const opencodeResults = await client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "opencode",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
});
|
||||
assertSuggestionEntry(
|
||||
opencodeResults,
|
||||
{ path: ".opencode/settings.json", kind: "file" },
|
||||
"Dot-directory search",
|
||||
);
|
||||
|
||||
const ignoredResults = await client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "ignored",
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
limit: 20,
|
||||
});
|
||||
assertSuggestionPayload(ignoredResults, "Ignored file search");
|
||||
if (ignoredResults.entries.some((entry) => entry.path === "ignored.secret")) {
|
||||
throw new Error(
|
||||
`Ignored file search returned ignored.secret: ${JSON.stringify(ignoredResults.entries)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
await removeTempDir(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCliDaemon({ appPath, env }) {
|
||||
console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim");
|
||||
await runCliShimCommand({
|
||||
@@ -677,9 +769,10 @@ async function smokePackagedDesktopApp({ appPath }) {
|
||||
const cliEnv = createDefaultDaemonEnv();
|
||||
await smokeCliShim({ appPath, env: cliEnv });
|
||||
await smokeCliTerminal({ appPath, env: cliEnv });
|
||||
await smokeWorkspaceSearch({ host: message.status.listen });
|
||||
await stopDaemonForCleanup();
|
||||
console.log(
|
||||
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status and terminal smoke succeeded`,
|
||||
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status, terminal smoke, and workspace search succeeded`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (smokeStarted && !daemonStopped) {
|
||||
|
||||
@@ -74,6 +74,15 @@ describe("desktop packaging", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("unpacks native workspace search packages", () => {
|
||||
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
|
||||
|
||||
expect(config).toContain("node_modules/@ff-labs/fff-node/**/*");
|
||||
expect(config).toContain("node_modules/@ff-labs/fff-bin-*/**/*");
|
||||
expect(config).toContain("node_modules/ffi-rs/**/*");
|
||||
expect(config).toContain("node_modules/@yuuang/ffi-rs-*/**/*");
|
||||
});
|
||||
|
||||
it("excludes package debug/source files from the packaged app", () => {
|
||||
const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8");
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@ff-labs/fff-node": "^0.9.6",
|
||||
"@getpaseo/client": "0.1.102",
|
||||
"@getpaseo/highlight": "0.1.102",
|
||||
"@getpaseo/protocol": "0.1.102",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
||||
import { isPlatform } from "../../test-utils/platform.js";
|
||||
import { searchHomeDirectories } from "./home-directories.js";
|
||||
import { clearWorkspaceSearchCacheForTests, searchWorkspaceEntries } from "./workspace-entries.js";
|
||||
|
||||
const isWindows = isPlatform("win32");
|
||||
|
||||
@@ -204,6 +206,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
});
|
||||
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
|
||||
mkdirSync(path.join(outsideDir, "escaped"), { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: workspaceDir, stdio: "ignore" });
|
||||
|
||||
writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n");
|
||||
writeFileSync(
|
||||
@@ -218,6 +221,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearWorkspaceSearchCacheForTests();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -322,17 +326,17 @@ describe("searchWorkspaceEntries", () => {
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect(basenameResults).toEqual([
|
||||
{ path: "src/file.ts", kind: "file" },
|
||||
{ path: "packages/app/src/file.ts", kind: "file" },
|
||||
]);
|
||||
expect(suffixResults).toEqual([
|
||||
{ path: "src/file.ts", kind: "file" },
|
||||
{ path: "packages/app/src/file.ts", kind: "file" },
|
||||
]);
|
||||
const expectedMatches = [
|
||||
{ path: "src/file.ts", kind: "file" as const },
|
||||
{ path: "packages/app/src/file.ts", kind: "file" as const },
|
||||
];
|
||||
expect(basenameResults).toHaveLength(2);
|
||||
expect(basenameResults).toEqual(expect.arrayContaining(expectedMatches));
|
||||
expect(suffixResults).toHaveLength(2);
|
||||
expect(suffixResults).toEqual(expect.arrayContaining(expectedMatches));
|
||||
});
|
||||
|
||||
it("suffix mode resolves exact workspace file paths before broad traversal", async () => {
|
||||
it("suffix mode resolves exact workspace file paths", async () => {
|
||||
const targetPath = path.join(
|
||||
workspaceDir,
|
||||
"packages",
|
||||
@@ -353,7 +357,6 @@ describe("searchWorkspaceEntries", () => {
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
maxEntriesScanned: 1,
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
@@ -364,7 +367,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("suffix mode resolves explicit hidden file paths without broad hidden traversal", async () => {
|
||||
it("suffix mode resolves explicit dot-prefixed file paths", async () => {
|
||||
const targetPath = path.join(workspaceDir, ".dev", "paseo-home", "daemon.log");
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
writeFileSync(targetPath, "daemon log\n");
|
||||
@@ -376,13 +379,40 @@ describe("searchWorkspaceEntries", () => {
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
maxEntriesScanned: 1,
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ path: ".dev/paseo-home/daemon.log", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("suffix mode finds files under allowlisted hidden workspace directories", async () => {
|
||||
it("finds dot-prefixed files and files under dot-prefixed directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".opencode"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".env.local"), "PASEO_TEST=1\n");
|
||||
writeFileSync(path.join(workspaceDir, ".opencode", "settings.json"), "{}");
|
||||
|
||||
const envResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "env",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
const opencodeResults = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "opencode",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
});
|
||||
|
||||
expect(envResults).toContainEqual({ path: ".env.local", kind: "file" });
|
||||
expect(opencodeResults).toContainEqual({ path: ".opencode", kind: "directory" });
|
||||
expect(opencodeResults).toContainEqual({
|
||||
path: ".opencode/settings.json",
|
||||
kind: "file",
|
||||
});
|
||||
});
|
||||
|
||||
it("suffix mode finds files under dot-prefixed workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
mkdirSync(path.join(workspaceDir, ".github", "workflows"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
@@ -409,7 +439,7 @@ describe("searchWorkspaceEntries", () => {
|
||||
expect(githubResults).toEqual([{ path: ".github/workflows/ci.yml", kind: "file" }]);
|
||||
});
|
||||
|
||||
it("does not broadly traverse unlisted hidden workspace directories", async () => {
|
||||
it("searches files under arbitrary dot-prefixed workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".dev", "cache"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".dev", "cache", "needle.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "src", "needle.ts"), "");
|
||||
@@ -423,10 +453,15 @@ describe("searchWorkspaceEntries", () => {
|
||||
matchMode: "suffix",
|
||||
});
|
||||
|
||||
expect(results).toEqual([{ path: "src/needle.ts", kind: "file" }]);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: ".dev/cache/needle.ts", kind: "file" },
|
||||
{ path: "src/needle.ts", kind: "file" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not suggest hidden directories even when includeDirectories is true", async () => {
|
||||
it("suggests dot-prefixed directories when includeDirectories is true", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
|
||||
@@ -439,35 +474,52 @@ describe("searchWorkspaceEntries", () => {
|
||||
matchMode: "fuzzy",
|
||||
});
|
||||
|
||||
expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(results).toContainEqual({ path: ".claude", kind: "directory" });
|
||||
expect(results).toContainEqual({
|
||||
path: ".claude/settings.local.json",
|
||||
kind: "file",
|
||||
});
|
||||
});
|
||||
|
||||
it("path mode does not suggest hidden workspace directories", async () => {
|
||||
it("path-style queries include dot-prefixed workspace directories", async () => {
|
||||
mkdirSync(path.join(workspaceDir, ".claude"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, ".claude", "settings.local.json"), "{}");
|
||||
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "./",
|
||||
query: "./.claude",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "fuzzy",
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
path: "README.md",
|
||||
expect(results).toContainEqual({ path: ".claude", kind: "directory" });
|
||||
expect(results).not.toContainEqual({
|
||||
path: ".claude/settings.local.json",
|
||||
kind: "file",
|
||||
});
|
||||
expect(results.some((entry) => entry.path === ".claude" && entry.kind === "directory")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("constrains fuzzy path-style queries to the typed parent directory", async () => {
|
||||
mkdirSync(path.join(workspaceDir, "src", "nested"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "src", "config.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "src", "nested", "config.ts"), "");
|
||||
writeFileSync(path.join(workspaceDir, "docs", "config.ts"), "");
|
||||
|
||||
const results = await searchWorkspaceEntries({
|
||||
cwd: workspaceDir,
|
||||
query: "src/co",
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "fuzzy",
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({ path: "src/components", kind: "directory" });
|
||||
expect(results).toContainEqual({ path: "src/config.ts", kind: "file" });
|
||||
expect(results).not.toContainEqual({ path: "src/nested/config.ts", kind: "file" });
|
||||
expect(results).not.toContainEqual({ path: "docs/config.ts", kind: "file" });
|
||||
});
|
||||
|
||||
it("does not traverse .git while searching workspace files", async () => {
|
||||
@@ -513,7 +565,8 @@ describe("searchWorkspaceEntries", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
|
||||
it("respects workspace ignore rules for node_modules", async () => {
|
||||
writeFileSync(path.join(workspaceDir, ".gitignore"), "node_modules/\n");
|
||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), "");
|
||||
|
||||
@@ -532,7 +585,6 @@ describe("searchWorkspaceEntries", () => {
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntriesScanned: 60,
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
@@ -542,11 +594,12 @@ describe("searchWorkspaceEntries", () => {
|
||||
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores common build/cache directories so large generated trees do not exhaust scan budget", async () => {
|
||||
it("respects workspace ignore rules for generated trees", async () => {
|
||||
mkdirSync(path.join(workspaceDir, "packages", "app", "src"), { recursive: true });
|
||||
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "needle.ts"), "");
|
||||
|
||||
const heavyDirs = ["dist", "build", "target", "out", "coverage", "vendor", "__pycache__"];
|
||||
writeFileSync(path.join(workspaceDir, ".gitignore"), `${heavyDirs.join("/\n")}/\n`);
|
||||
for (const heavyDir of heavyDirs) {
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
mkdirSync(path.join(workspaceDir, heavyDir, `bundle-${index}`), { recursive: true });
|
||||
@@ -560,7 +613,6 @@ describe("searchWorkspaceEntries", () => {
|
||||
limit: 20,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
maxEntriesScanned: 80,
|
||||
});
|
||||
|
||||
expect(results).toContainEqual({
|
||||
478
packages/server/src/server/search/home-directories.ts
Normal file
478
packages/server/src/server/search/home-directories.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { readdir, realpath, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isPathInsideRoot } from "../../utils/path.js";
|
||||
|
||||
export interface SearchHomeDirectoriesOptions {
|
||||
homeDir: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
maxDepth?: number;
|
||||
maxDirectoriesScanned?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_MAX_DEPTH = 12;
|
||||
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
|
||||
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
|
||||
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
|
||||
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
|
||||
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
|
||||
const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([
|
||||
"node_modules",
|
||||
"venv",
|
||||
"env",
|
||||
"virtualenv",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
"out",
|
||||
"coverage",
|
||||
"vendor",
|
||||
"__pycache__",
|
||||
".git",
|
||||
]);
|
||||
|
||||
interface QueryParts {
|
||||
isPathQuery: boolean;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface RankedDirectory {
|
||||
absolutePath: string;
|
||||
matchTier: number;
|
||||
segmentIndex: number;
|
||||
matchOffset: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface ChildDirectoryEntry {
|
||||
name: string;
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
interface DirectoryListCacheEntry {
|
||||
expiresAt: number;
|
||||
entries: ChildDirectoryEntry[];
|
||||
}
|
||||
|
||||
const directoryListCache = new Map<string, DirectoryListCacheEntry>();
|
||||
|
||||
export async function searchHomeDirectories(
|
||||
options: SearchHomeDirectoriesOptions,
|
||||
): Promise<string[]> {
|
||||
const query = options.query.trim();
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const homeRoot = await resolveDirectory(options.homeDir);
|
||||
if (!homeRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParts = normalizeQueryParts(query, homeRoot);
|
||||
if (!queryParts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (queryParts.isPathQuery) {
|
||||
return searchWithinParentDirectory({
|
||||
homeRoot,
|
||||
parentPart: queryParts.parentPart,
|
||||
searchTerm: queryParts.searchTerm,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
return searchAcrossHomeTree({
|
||||
homeRoot,
|
||||
searchTerm: queryParts.searchTerm,
|
||||
limit,
|
||||
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
||||
maxDirectoriesScanned: options.maxDirectoriesScanned ?? DEFAULT_MAX_DIRECTORIES_SCANNED,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
const candidate = limit ?? DEFAULT_LIMIT;
|
||||
if (!Number.isFinite(candidate)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const bounded = Math.trunc(candidate);
|
||||
return Math.max(1, Math.min(MAX_LIMIT, bounded));
|
||||
}
|
||||
|
||||
async function searchWithinParentDirectory(input: {
|
||||
homeRoot: string;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
limit: number;
|
||||
}): Promise<string[]> {
|
||||
const parentPath = path.resolve(input.homeRoot, input.parentPart || ".");
|
||||
const parentRoot = await resolveDirectory(parentPath);
|
||||
if (!parentRoot || !isPathInsideRoot(input.homeRoot, parentRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchLower = input.searchTerm.toLowerCase();
|
||||
const ranked: RankedDirectory[] = [];
|
||||
const entries = await listChildDirectories({
|
||||
directory: parentRoot,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranked.push(
|
||||
rankDirectory({
|
||||
absolutePath: entry.absolutePath,
|
||||
homeRoot: input.homeRoot,
|
||||
searchLower,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return dedupeAndSort(ranked).slice(0, input.limit);
|
||||
}
|
||||
|
||||
async function searchAcrossHomeTree(input: {
|
||||
homeRoot: string;
|
||||
searchTerm: string;
|
||||
limit: number;
|
||||
maxDepth: number;
|
||||
maxDirectoriesScanned: number;
|
||||
}): Promise<string[]> {
|
||||
const queue: Array<{ directory: string; depth: number }> = [
|
||||
{ directory: input.homeRoot, depth: 0 },
|
||||
];
|
||||
const visited = new Set<string>([input.homeRoot]);
|
||||
const ranked: RankedDirectory[] = [];
|
||||
let scanned = 0;
|
||||
const searchLower = input.searchTerm.toLowerCase();
|
||||
|
||||
for (
|
||||
let queueIndex = 0;
|
||||
queueIndex < queue.length && scanned < input.maxDirectoriesScanned;
|
||||
queueIndex += 1
|
||||
) {
|
||||
const current = queue[queueIndex];
|
||||
if (!current) continue;
|
||||
const entries = await listChildDirectories({
|
||||
directory: current.directory,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const resolvedCandidate = entry.absolutePath;
|
||||
if (visited.has(resolvedCandidate)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(resolvedCandidate);
|
||||
scanned += 1;
|
||||
|
||||
const relativePath = normalizeRelativePath(input.homeRoot, resolvedCandidate);
|
||||
if (
|
||||
relativePath.toLowerCase().includes(searchLower) ||
|
||||
entry.name.toLowerCase().includes(searchLower)
|
||||
) {
|
||||
ranked.push(
|
||||
rankDirectory({
|
||||
absolutePath: resolvedCandidate,
|
||||
homeRoot: input.homeRoot,
|
||||
searchLower,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (current.depth < input.maxDepth && scanned < input.maxDirectoriesScanned) {
|
||||
queue.push({ directory: resolvedCandidate, depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeAndSort(ranked).slice(0, input.limit);
|
||||
}
|
||||
|
||||
function dedupeAndSort(ranked: RankedDirectory[]): string[] {
|
||||
const byPath = new Map<string, RankedDirectory>();
|
||||
for (const entry of ranked) {
|
||||
const existing = byPath.get(entry.absolutePath);
|
||||
if (!existing || compareRankedDirectories(entry, existing) < 0) {
|
||||
byPath.set(entry.absolutePath, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values())
|
||||
.sort(compareRankedDirectories)
|
||||
.map((entry) => entry.absolutePath);
|
||||
}
|
||||
|
||||
function compareRankedDirectories(left: RankedDirectory, right: RankedDirectory): number {
|
||||
if (left.matchTier !== right.matchTier) {
|
||||
return left.matchTier - right.matchTier;
|
||||
}
|
||||
if (left.segmentIndex !== right.segmentIndex) {
|
||||
return left.segmentIndex - right.segmentIndex;
|
||||
}
|
||||
if (left.matchOffset !== right.matchOffset) {
|
||||
return left.matchOffset - right.matchOffset;
|
||||
}
|
||||
if (left.depth !== right.depth) {
|
||||
return left.depth - right.depth;
|
||||
}
|
||||
return left.absolutePath.localeCompare(right.absolutePath);
|
||||
}
|
||||
|
||||
function rankDirectory(input: {
|
||||
absolutePath: string;
|
||||
homeRoot: string;
|
||||
searchLower: string;
|
||||
}): RankedDirectory {
|
||||
const relative = normalizeRelativePath(input.homeRoot, input.absolutePath);
|
||||
const relativeLower = relative.toLowerCase();
|
||||
const depth = relative === "." ? 0 : relative.split("/").length;
|
||||
const searchLower = input.searchLower;
|
||||
if (!searchLower) {
|
||||
return {
|
||||
absolutePath: input.absolutePath,
|
||||
matchTier: 3,
|
||||
segmentIndex: NO_SEGMENT_INDEX,
|
||||
matchOffset: 0,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
const segments = relativeLower === "." ? [] : relativeLower.split("/");
|
||||
const exactSegmentIndex = findSegmentMatchIndex(segments, (segment) => segment === searchLower);
|
||||
const prefixSegmentIndex = findSegmentMatchIndex(segments, (segment) =>
|
||||
segment.startsWith(searchLower),
|
||||
);
|
||||
const partialSegmentIndex = findSegmentMatchIndex(segments, (segment) =>
|
||||
segment.includes(searchLower),
|
||||
);
|
||||
const matchOffset = relativeLower.indexOf(searchLower);
|
||||
let matchTier = 4;
|
||||
let segmentIndex = NO_SEGMENT_INDEX;
|
||||
|
||||
if (exactSegmentIndex >= 0) {
|
||||
matchTier = 0;
|
||||
segmentIndex = exactSegmentIndex;
|
||||
} else if (prefixSegmentIndex >= 0) {
|
||||
matchTier = 1;
|
||||
segmentIndex = prefixSegmentIndex;
|
||||
} else if (partialSegmentIndex >= 0) {
|
||||
matchTier = 2;
|
||||
segmentIndex = partialSegmentIndex;
|
||||
} else if (relativeLower.startsWith(searchLower)) {
|
||||
matchTier = 3;
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: input.absolutePath,
|
||||
matchTier,
|
||||
segmentIndex,
|
||||
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
|
||||
depth,
|
||||
};
|
||||
}
|
||||
|
||||
function findSegmentMatchIndex(
|
||||
segments: string[],
|
||||
predicate: (segment: string) => boolean,
|
||||
): number {
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
if (predicate(segment)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(homeRoot: string, absolutePath: string): string {
|
||||
const relative = path.relative(homeRoot, absolutePath);
|
||||
if (!relative) {
|
||||
return ".";
|
||||
}
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function normalizeQueryParts(query: string, homeRoot: string): QueryParts | null {
|
||||
const typedQuery = query.trim().replace(/\\/g, "/");
|
||||
let normalized = typedQuery;
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only treat the query as a literal path when the user explicitly roots it
|
||||
// with ~, ~/, ./, or an absolute path. Bare queries like "faro/main" are
|
||||
// search terms, not paths.
|
||||
let isRooted = false;
|
||||
|
||||
if (normalized.startsWith("~")) {
|
||||
isRooted = true;
|
||||
normalized = normalized.slice(1);
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (path.isAbsolute(normalized)) {
|
||||
isRooted = true;
|
||||
const absolute = path.resolve(normalized);
|
||||
if (!isPathInsideRoot(homeRoot, absolute)) {
|
||||
return null;
|
||||
}
|
||||
normalized = normalizeRelativePath(homeRoot, absolute);
|
||||
}
|
||||
|
||||
if (normalized.startsWith("./")) {
|
||||
isRooted = true;
|
||||
}
|
||||
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
// Treat "~" and "~/" as a request to browse the home root.
|
||||
if (typedQuery === "~" || typedQuery === "~/") {
|
||||
return {
|
||||
isPathQuery: true,
|
||||
parentPart: "",
|
||||
searchTerm: "",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPathQuery = isRooted && normalized.includes("/");
|
||||
if (!isPathQuery) {
|
||||
return {
|
||||
isPathQuery: false,
|
||||
parentPart: "",
|
||||
searchTerm: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
const parentPart = normalized.slice(0, slashIndex);
|
||||
const searchTerm = normalized.slice(slashIndex + 1);
|
||||
|
||||
return {
|
||||
isPathQuery: true,
|
||||
parentPart,
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDirectory(inputPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await realpath(path.resolve(inputPath));
|
||||
const stats = await stat(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listChildDirectories(input: {
|
||||
directory: string;
|
||||
homeRoot: string;
|
||||
}): Promise<ChildDirectoryEntry[]> {
|
||||
const now = Date.now();
|
||||
const cached = directoryListCache.get(input.directory);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.entries;
|
||||
}
|
||||
|
||||
const dirents = await readdir(input.directory, { withFileTypes: true }).catch(
|
||||
() => [] as Dirent[],
|
||||
);
|
||||
const candidates = dirents.filter(
|
||||
(dirent) =>
|
||||
!isHiddenDirectoryName(dirent.name) &&
|
||||
!isIgnoredSuggestionDirectoryName(dirent.name) &&
|
||||
(dirent.isDirectory() || dirent.isSymbolicLink()),
|
||||
);
|
||||
const resolved = await Promise.all(
|
||||
candidates.map(async (dirent) => {
|
||||
const candidatePath = path.join(input.directory, dirent.name);
|
||||
const absolutePath = await resolveDirectoryCandidate({
|
||||
candidatePath,
|
||||
dirent,
|
||||
homeRoot: input.homeRoot,
|
||||
});
|
||||
return absolutePath ? { name: dirent.name, absolutePath } : null;
|
||||
}),
|
||||
);
|
||||
const entries: ChildDirectoryEntry[] = resolved.filter(
|
||||
(entry): entry is ChildDirectoryEntry => entry !== null,
|
||||
);
|
||||
|
||||
setDirectoryListCache(input.directory, {
|
||||
expiresAt: now + DIRECTORY_LIST_CACHE_TTL_MS,
|
||||
entries,
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveDirectoryCandidate(input: {
|
||||
candidatePath: string;
|
||||
dirent: Dirent;
|
||||
homeRoot: string;
|
||||
}): Promise<string | null> {
|
||||
if (input.dirent.isDirectory()) {
|
||||
const resolved = path.resolve(input.candidatePath);
|
||||
return isPathInsideRoot(input.homeRoot, resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
const resolved = await resolveDirectory(input.candidatePath);
|
||||
if (!resolved || !isPathInsideRoot(input.homeRoot, resolved)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isHiddenDirectoryName(name: string): boolean {
|
||||
return name.startsWith(".");
|
||||
}
|
||||
|
||||
function isIgnoredSuggestionDirectoryName(name: string): boolean {
|
||||
return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name);
|
||||
}
|
||||
|
||||
function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {
|
||||
directoryListCache.set(cacheKey, entry);
|
||||
pruneDirectoryListCache();
|
||||
}
|
||||
|
||||
function pruneDirectoryListCache(): void {
|
||||
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of directoryListCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
directoryListCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = directoryListCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
return;
|
||||
}
|
||||
directoryListCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
706
packages/server/src/server/search/workspace-entries.ts
Normal file
706
packages/server/src/server/search/workspace-entries.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type {
|
||||
DirItem,
|
||||
FileFinder,
|
||||
FileItem,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
Result,
|
||||
} from "@ff-labs/fff-node";
|
||||
import { isPathInsideRoot } from "../../utils/path.js";
|
||||
|
||||
export type WorkspaceSuggestionKind = "file" | "directory";
|
||||
|
||||
export interface WorkspaceSuggestionEntry {
|
||||
path: string;
|
||||
kind: WorkspaceSuggestionKind;
|
||||
}
|
||||
|
||||
export type WorkspaceMatchMode = "fuzzy" | "suffix";
|
||||
|
||||
export interface SearchWorkspaceEntriesOptions {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
includeFiles?: boolean;
|
||||
includeDirectories?: boolean;
|
||||
matchMode?: WorkspaceMatchMode;
|
||||
}
|
||||
|
||||
type FffModule = typeof import("@ff-labs/fff-node");
|
||||
|
||||
interface WorkspaceFinderCacheEntry {
|
||||
finder: FileFinder;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface WorkspaceQueryParts {
|
||||
isPathQuery: boolean;
|
||||
normalizedQuery: string;
|
||||
parentPart: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
interface PackagedFffPackageJson {
|
||||
main?: unknown;
|
||||
exports?: unknown;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 30;
|
||||
const MAX_LIMIT = 100;
|
||||
const FFF_SCAN_WAIT_TIMEOUT_MS = 5_000;
|
||||
const FFF_SEARCH_PAGE_SIZE = 1_000;
|
||||
const FFF_FINDER_CACHE_TTL_MS = 120_000;
|
||||
const FFF_FINDER_CACHE_MAX_ENTRIES = 24;
|
||||
|
||||
const workspaceFinderCache = new Map<string, WorkspaceFinderCacheEntry>();
|
||||
let fffModulePromise: Promise<FffModule> | null = null;
|
||||
|
||||
export async function searchWorkspaceEntries(
|
||||
options: SearchWorkspaceEntriesOptions,
|
||||
): Promise<WorkspaceSuggestionEntry[]> {
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const includeDirectories = options.includeDirectories ?? true;
|
||||
const includeFiles = options.includeFiles ?? false;
|
||||
if (!includeDirectories && !includeFiles) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaceRoot = await resolveDirectory(options.cwd);
|
||||
if (!workspaceRoot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryParts = normalizeWorkspaceQueryParts(options.query, workspaceRoot);
|
||||
if (!queryParts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matchMode = options.matchMode ?? "fuzzy";
|
||||
const exactEntry =
|
||||
matchMode === "suffix"
|
||||
? await resolveWorkspaceExactEntry({
|
||||
workspaceRoot,
|
||||
query: options.query,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
})
|
||||
: null;
|
||||
if (exactEntry && limit <= 1) {
|
||||
return [exactEntry];
|
||||
}
|
||||
|
||||
const searchQuery =
|
||||
matchMode === "suffix" || queryParts.isPathQuery
|
||||
? queryParts.normalizedQuery
|
||||
: queryParts.searchTerm;
|
||||
const parentConstraint =
|
||||
matchMode === "fuzzy" && queryParts.isPathQuery ? queryParts.parentPart : null;
|
||||
const finder = await getWorkspaceFinder(workspaceRoot);
|
||||
const scan = await finder.waitForScan(FFF_SCAN_WAIT_TIMEOUT_MS);
|
||||
if (!scan.ok) {
|
||||
throw new Error(`Workspace search scan failed: ${scan.error}`);
|
||||
}
|
||||
if (!scan.value) {
|
||||
throw new Error(`Workspace search scan timed out after ${FFF_SCAN_WAIT_TIMEOUT_MS}ms`);
|
||||
}
|
||||
|
||||
const candidates = await searchFffEntries({
|
||||
finder,
|
||||
query: searchQuery,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
});
|
||||
const entries = await normalizeFffEntries({
|
||||
workspaceRoot,
|
||||
candidates,
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
matchMode,
|
||||
parentConstraint,
|
||||
suffixQuery: searchQuery,
|
||||
});
|
||||
const deduped = dedupeWorkspaceEntries(entries);
|
||||
return exactEntry
|
||||
? prependWorkspaceEntry(exactEntry, deduped).slice(0, limit)
|
||||
: deduped.slice(0, limit);
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCacheForTests(): void {
|
||||
for (const entry of workspaceFinderCache.values()) {
|
||||
destroyFinder(entry.finder);
|
||||
}
|
||||
workspaceFinderCache.clear();
|
||||
fffModulePromise = null;
|
||||
}
|
||||
|
||||
async function getWorkspaceFinder(workspaceRoot: string): Promise<FileFinder> {
|
||||
pruneWorkspaceFinderCache();
|
||||
|
||||
const now = Date.now();
|
||||
const cached = workspaceFinderCache.get(workspaceRoot);
|
||||
if (cached && cached.expiresAt > now && !cached.finder.isDestroyed) {
|
||||
cached.expiresAt = now + FFF_FINDER_CACHE_TTL_MS;
|
||||
return cached.finder;
|
||||
}
|
||||
if (cached) {
|
||||
workspaceFinderCache.delete(workspaceRoot);
|
||||
destroyFinder(cached.finder);
|
||||
}
|
||||
|
||||
const { FileFinder } = await loadFffModule();
|
||||
pruneWorkspaceFinderCache();
|
||||
|
||||
const afterImportCached = workspaceFinderCache.get(workspaceRoot);
|
||||
if (
|
||||
afterImportCached &&
|
||||
afterImportCached.expiresAt > Date.now() &&
|
||||
!afterImportCached.finder.isDestroyed
|
||||
) {
|
||||
afterImportCached.expiresAt = Date.now() + FFF_FINDER_CACHE_TTL_MS;
|
||||
return afterImportCached.finder;
|
||||
}
|
||||
|
||||
const created = FileFinder.create({
|
||||
basePath: workspaceRoot,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
...(await getRootScanningOptions(workspaceRoot)),
|
||||
});
|
||||
if (!created.ok) {
|
||||
throw new Error(`Workspace search initialization failed: ${created.error}`);
|
||||
}
|
||||
|
||||
workspaceFinderCache.set(workspaceRoot, {
|
||||
finder: created.value,
|
||||
expiresAt: Date.now() + FFF_FINDER_CACHE_TTL_MS,
|
||||
});
|
||||
pruneWorkspaceFinderCache();
|
||||
return created.value;
|
||||
}
|
||||
|
||||
async function loadFffModule(): Promise<FffModule> {
|
||||
if (!fffModulePromise) {
|
||||
const packagedEntrypoint = resolvePackagedFffEntrypoint();
|
||||
fffModulePromise = packagedEntrypoint
|
||||
? import(pathToFileURL(packagedEntrypoint).href)
|
||||
: import("@ff-labs/fff-node");
|
||||
}
|
||||
return fffModulePromise;
|
||||
}
|
||||
|
||||
function resolvePackagedFffEntrypoint(): string | null {
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||
if (!resourcesPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageRoot = path.join(
|
||||
resourcesPath,
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
"@ff-labs",
|
||||
"fff-node",
|
||||
);
|
||||
// Load the unpacked package entry declared by FFF instead of duplicating its dist layout here.
|
||||
const candidate = resolvePackagedFffEntrypointFromPackage(packageRoot);
|
||||
return candidate && existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
async function searchFffEntries(input: {
|
||||
finder: FileFinder;
|
||||
query: string;
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
}): Promise<WorkspaceSuggestionEntry[]> {
|
||||
if (input.includeDirectories && input.includeFiles) {
|
||||
const result = unwrapFffResult(
|
||||
input.finder.mixedSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace mixed search failed",
|
||||
);
|
||||
return result.items.map((item) => normalizeMixedItem(item));
|
||||
}
|
||||
|
||||
if (input.includeFiles) {
|
||||
const result = unwrapFffResult(
|
||||
input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace file search failed",
|
||||
);
|
||||
return result.items.map((item) => normalizeFileItem(item));
|
||||
}
|
||||
|
||||
const result = unwrapFffResult(
|
||||
input.finder.directorySearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace directory search failed",
|
||||
);
|
||||
// FFF directory search only returns directories whose own names match. File search lets us
|
||||
// derive matching ancestor directories from files nested below them.
|
||||
const fileResult = unwrapFffResult(
|
||||
input.finder.fileSearch(input.query, { pageSize: FFF_SEARCH_PAGE_SIZE }),
|
||||
"Workspace file search failed",
|
||||
);
|
||||
return [
|
||||
...result.items.map((item) => normalizeDirectoryItem(item)),
|
||||
...fileResult.items.map((item) => normalizeFileItem(item)),
|
||||
];
|
||||
}
|
||||
|
||||
function unwrapFffResult<T>(result: Result<T>, message: string): T {
|
||||
if (result.ok) {
|
||||
return result.value;
|
||||
}
|
||||
throw new Error(`${message}: ${result.error}`);
|
||||
}
|
||||
|
||||
function normalizeMixedItem(item: MixedItem): WorkspaceSuggestionEntry {
|
||||
return item.type === "file" ? normalizeFileItem(item.item) : normalizeDirectoryItem(item.item);
|
||||
}
|
||||
|
||||
function normalizeFileItem(item: FileItem): WorkspaceSuggestionEntry {
|
||||
return {
|
||||
path: normalizeFffRelativePath(item.relativePath),
|
||||
kind: "file",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDirectoryItem(item: DirItem): WorkspaceSuggestionEntry {
|
||||
return {
|
||||
path: normalizeFffRelativePath(item.relativePath).replace(/\/+$/, ""),
|
||||
kind: "directory",
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeFffEntries(input: {
|
||||
workspaceRoot: string;
|
||||
candidates: WorkspaceSuggestionEntry[];
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
parentConstraint: string | null;
|
||||
suffixQuery: string;
|
||||
}): Promise<WorkspaceSuggestionEntry[]> {
|
||||
const entries: WorkspaceSuggestionEntry[] = [];
|
||||
for (const candidate of input.candidates) {
|
||||
for (const entry of expandFffCandidate({
|
||||
candidate,
|
||||
includeDirectories: input.includeDirectories,
|
||||
matchMode: input.matchMode,
|
||||
query: input.suffixQuery,
|
||||
})) {
|
||||
if (entry.kind === "directory" && !input.includeDirectories) {
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "file" && !input.includeFiles) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
input.parentConstraint !== null &&
|
||||
!workspaceEntryIsDirectChildOfParent({
|
||||
relativePath: entry.path,
|
||||
parentPart: input.parentConstraint,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
input.matchMode === "suffix" &&
|
||||
!workspaceEntryMatchesSuffixQuery({
|
||||
relativePath: entry.path,
|
||||
query: input.suffixQuery,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const resolved = await resolveIndexedWorkspaceEntry({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
relativePath: entry.path,
|
||||
expectedKind: entry.kind,
|
||||
});
|
||||
if (resolved) {
|
||||
entries.push(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function expandFffCandidate(input: {
|
||||
candidate: WorkspaceSuggestionEntry;
|
||||
includeDirectories: boolean;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
query: string;
|
||||
}): WorkspaceSuggestionEntry[] {
|
||||
if (!input.includeDirectories || input.candidate.kind !== "file") {
|
||||
return [input.candidate];
|
||||
}
|
||||
|
||||
return [
|
||||
...collectMatchingAncestorDirectories({
|
||||
relativePath: input.candidate.path,
|
||||
matchMode: input.matchMode,
|
||||
query: input.query,
|
||||
}),
|
||||
input.candidate,
|
||||
];
|
||||
}
|
||||
|
||||
function collectMatchingAncestorDirectories(input: {
|
||||
relativePath: string;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
query: string;
|
||||
}): WorkspaceSuggestionEntry[] {
|
||||
const segments = normalizeFffRelativePath(input.relativePath).split("/").filter(Boolean);
|
||||
const directories: WorkspaceSuggestionEntry[] = [];
|
||||
for (let segmentCount = 1; segmentCount < segments.length; segmentCount += 1) {
|
||||
const directoryPath = segments.slice(0, segmentCount).join("/");
|
||||
if (
|
||||
workspaceDirectoryMatchesQuery({
|
||||
relativePath: directoryPath,
|
||||
matchMode: input.matchMode,
|
||||
query: input.query,
|
||||
})
|
||||
) {
|
||||
directories.push({ path: directoryPath, kind: "directory" });
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
function workspaceDirectoryMatchesQuery(input: {
|
||||
relativePath: string;
|
||||
matchMode: WorkspaceMatchMode;
|
||||
query: string;
|
||||
}): boolean {
|
||||
const normalizedQuery = normalizeFffRelativePath(input.query)
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
if (input.matchMode === "suffix") {
|
||||
return workspaceEntryMatchesSuffixQuery({
|
||||
relativePath: input.relativePath,
|
||||
query: normalizedQuery,
|
||||
});
|
||||
}
|
||||
return normalizeFffRelativePath(input.relativePath).toLowerCase().includes(normalizedQuery);
|
||||
}
|
||||
|
||||
async function resolveIndexedWorkspaceEntry(input: {
|
||||
workspaceRoot: string;
|
||||
relativePath: string;
|
||||
expectedKind: WorkspaceSuggestionKind;
|
||||
}): Promise<WorkspaceSuggestionEntry | null> {
|
||||
const candidatePath = path.resolve(input.workspaceRoot, input.relativePath);
|
||||
if (!isPathInsideRoot(input.workspaceRoot, candidatePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
if (input.expectedKind === "file" && !stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (input.expectedKind === "directory" && !stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
|
||||
kind: input.expectedKind,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveWorkspaceExactEntry(input: {
|
||||
workspaceRoot: string;
|
||||
query: string;
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
}): Promise<WorkspaceSuggestionEntry | null> {
|
||||
const normalized = normalizeFffRelativePath(input.query)
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidatePath = path.isAbsolute(normalized)
|
||||
? path.resolve(normalized)
|
||||
: path.resolve(input.workspaceRoot, normalized);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await realpath(candidatePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInsideRoot(input.workspaceRoot, resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await stat(resolvedPath).catch(() => null);
|
||||
if (!stats) {
|
||||
return null;
|
||||
}
|
||||
if (stats.isFile() && input.includeFiles) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
|
||||
kind: "file",
|
||||
};
|
||||
}
|
||||
if (stats.isDirectory() && input.includeDirectories) {
|
||||
return {
|
||||
path: normalizeRelativePath(input.workspaceRoot, candidatePath),
|
||||
kind: "directory",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prependWorkspaceEntry(
|
||||
entry: WorkspaceSuggestionEntry,
|
||||
entries: WorkspaceSuggestionEntry[],
|
||||
): WorkspaceSuggestionEntry[] {
|
||||
return [
|
||||
entry,
|
||||
...entries.filter(
|
||||
(candidate) => candidate.kind !== entry.kind || candidate.path !== entry.path,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function dedupeWorkspaceEntries(entries: WorkspaceSuggestionEntry[]): WorkspaceSuggestionEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: WorkspaceSuggestionEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
const key = `${entry.kind}:${entry.path}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(entry);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function workspaceEntryMatchesSuffixQuery(input: { relativePath: string; query: string }): boolean {
|
||||
const querySegments = normalizeFffRelativePath(input.query)
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase());
|
||||
if (querySegments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathSegments = normalizeFffRelativePath(input.relativePath)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.toLowerCase());
|
||||
if (querySegments.length > pathSegments.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const offset = pathSegments.length - querySegments.length;
|
||||
return querySegments.every((segment, index) => pathSegments[offset + index] === segment);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceQueryParts(
|
||||
query: string,
|
||||
workspaceRoot: string,
|
||||
): WorkspaceQueryParts | null {
|
||||
let normalized = normalizeFffRelativePath(query.trim());
|
||||
let isPathQuery = normalized.startsWith("./") || normalized.startsWith("../");
|
||||
|
||||
if (path.isAbsolute(normalized)) {
|
||||
const absolute = path.resolve(normalized);
|
||||
if (!isPathInsideRoot(workspaceRoot, absolute)) {
|
||||
return null;
|
||||
}
|
||||
normalized = normalizeRelativePath(workspaceRoot, absolute);
|
||||
isPathQuery = true;
|
||||
}
|
||||
|
||||
normalized = normalized.replace(/^\.\/+/, "").replace(/\/{2,}/g, "/");
|
||||
isPathQuery = isPathQuery || normalized.includes("/");
|
||||
if (!normalized) {
|
||||
return {
|
||||
isPathQuery,
|
||||
normalizedQuery: "",
|
||||
parentPart: "",
|
||||
searchTerm: "",
|
||||
};
|
||||
}
|
||||
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
return {
|
||||
isPathQuery,
|
||||
normalizedQuery: normalized,
|
||||
parentPart: slashIndex >= 0 ? normalized.slice(0, slashIndex) : "",
|
||||
searchTerm: slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized,
|
||||
};
|
||||
}
|
||||
|
||||
async function getRootScanningOptions(
|
||||
workspaceRoot: string,
|
||||
): Promise<Pick<InitOptions, "enableFsRootScanning" | "enableHomeDirScanning">> {
|
||||
const homeRoot = await resolveDirectory(homedir());
|
||||
return {
|
||||
enableFsRootScanning: workspaceRoot === path.parse(workspaceRoot).root,
|
||||
enableHomeDirScanning: homeRoot === workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackagedFffEntrypointFromPackage(packageRoot: string): string | null {
|
||||
const packageJsonPath = path.join(packageRoot, "package.json");
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let packageJson: PackagedFffPackageJson;
|
||||
try {
|
||||
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackagedFffPackageJson;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entrypoint = getPackageEntrypoint(packageJson) ?? null;
|
||||
return entrypoint ? path.resolve(packageRoot, entrypoint) : null;
|
||||
}
|
||||
|
||||
function getPackageEntrypoint(packageJson: PackagedFffPackageJson): string | null {
|
||||
const exportedEntrypoint = getRootExportEntrypoint(packageJson.exports);
|
||||
if (exportedEntrypoint) {
|
||||
return exportedEntrypoint;
|
||||
}
|
||||
return typeof packageJson.main === "string" ? packageJson.main : null;
|
||||
}
|
||||
|
||||
function getRootExportEntrypoint(exportsField: unknown): string | null {
|
||||
if (typeof exportsField === "string") {
|
||||
return exportsField;
|
||||
}
|
||||
if (!exportsField || typeof exportsField !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootExport = (exportsField as Record<string, unknown>)["."];
|
||||
if (typeof rootExport === "string") {
|
||||
return rootExport;
|
||||
}
|
||||
if (!rootExport || typeof rootExport !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const conditions = rootExport as Record<string, unknown>;
|
||||
if (typeof conditions.import === "string") {
|
||||
return conditions.import;
|
||||
}
|
||||
return typeof conditions.default === "string" ? conditions.default : null;
|
||||
}
|
||||
|
||||
function workspaceEntryIsDirectChildOfParent(input: {
|
||||
relativePath: string;
|
||||
parentPart: string;
|
||||
}): boolean {
|
||||
const relativePath = normalizeFffRelativePath(input.relativePath).replace(/^\.\/+/, "");
|
||||
const parentPart = normalizeFffRelativePath(input.parentPart)
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
if (!parentPart) {
|
||||
return relativePath.length > 0 && !relativePath.includes("/");
|
||||
}
|
||||
if (!relativePath.startsWith(`${parentPart}/`)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const childPart = relativePath.slice(parentPart.length + 1);
|
||||
return childPart.length > 0 && !childPart.includes("/");
|
||||
}
|
||||
|
||||
function normalizeLimit(limit: number | undefined): number {
|
||||
const candidate = limit ?? DEFAULT_LIMIT;
|
||||
if (!Number.isFinite(candidate)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
const bounded = Math.trunc(candidate);
|
||||
return Math.max(1, Math.min(MAX_LIMIT, bounded));
|
||||
}
|
||||
|
||||
async function resolveDirectory(inputPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolved = await realpath(path.resolve(inputPath));
|
||||
const stats = await stat(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativePath(root: string, absolutePath: string): string {
|
||||
const relative = path.relative(root, absolutePath);
|
||||
if (!relative) {
|
||||
return ".";
|
||||
}
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function normalizeFffRelativePath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function pruneWorkspaceFinderCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of workspaceFinderCache) {
|
||||
if (entry.expiresAt <= now || entry.finder.isDestroyed) {
|
||||
workspaceFinderCache.delete(cacheKey);
|
||||
destroyFinder(entry.finder);
|
||||
}
|
||||
}
|
||||
|
||||
while (workspaceFinderCache.size > FFF_FINDER_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = workspaceFinderCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
return;
|
||||
}
|
||||
const oldest = workspaceFinderCache.get(oldestKey);
|
||||
workspaceFinderCache.delete(oldestKey);
|
||||
if (oldest) {
|
||||
destroyFinder(oldest.finder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroyFinder(finder: FileFinder): void {
|
||||
if (finder.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
finder.destroy();
|
||||
} catch {
|
||||
// Destroy is best-effort cleanup for native handles.
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,8 @@ import {
|
||||
type AgentUpdatesService,
|
||||
} from "./session/agent-updates/agent-updates-service.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||
import { searchHomeDirectories } from "./search/home-directories.js";
|
||||
import { searchWorkspaceEntries } from "./search/workspace-entries.js";
|
||||
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import type { Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,29 @@ const entries = [
|
||||
"packages/server/dist/server/terminal/terminal-worker-process.js",
|
||||
];
|
||||
|
||||
const FFF_BIN_PACKAGE = {
|
||||
darwin: { arm64: "@ff-labs/fff-bin-darwin-arm64", x64: "@ff-labs/fff-bin-darwin-x64" },
|
||||
linux: { arm64: "@ff-labs/fff-bin-linux-arm64-gnu", x64: "@ff-labs/fff-bin-linux-x64-gnu" },
|
||||
win32: { arm64: "@ff-labs/fff-bin-win32-arm64", x64: "@ff-labs/fff-bin-win32-x64" },
|
||||
};
|
||||
|
||||
const FFI_RS_PACKAGE = {
|
||||
darwin: { arm64: "@yuuang/ffi-rs-darwin-arm64", x64: "@yuuang/ffi-rs-darwin-x64" },
|
||||
linux: { arm64: "@yuuang/ffi-rs-linux-arm64-gnu", x64: "@yuuang/ffi-rs-linux-x64-gnu" },
|
||||
win32: {
|
||||
arm64: "@yuuang/ffi-rs-win32-arm64-msvc",
|
||||
x64: "@yuuang/ffi-rs-win32-x64-msvc",
|
||||
},
|
||||
};
|
||||
|
||||
function packageGlob(packageName) {
|
||||
return packageName ? [`node_modules/${packageName}/**`] : [];
|
||||
}
|
||||
|
||||
function packageForHost(packagesByPlatform) {
|
||||
return packagesByPlatform[process.platform]?.[process.arch] ?? null;
|
||||
}
|
||||
|
||||
// Files read at runtime via fs APIs rather than `require`. nft only
|
||||
// traces the module graph; data files have to be listed explicitly.
|
||||
const additionalInputs = [
|
||||
@@ -44,6 +67,11 @@ const additionalInputs = [
|
||||
// the Nix derivation builds for one platform at a time and ships only
|
||||
// its own binaries.
|
||||
`node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/**`,
|
||||
// FFF resolves its native search library and ffi-rs binding dynamically.
|
||||
"node_modules/@ff-labs/fff-node/**",
|
||||
"node_modules/ffi-rs/**",
|
||||
...packageGlob(packageForHost(FFF_BIN_PACKAGE)),
|
||||
...packageGlob(packageForHost(FFI_RS_PACKAGE)),
|
||||
];
|
||||
|
||||
// Trace.
|
||||
@@ -65,6 +93,10 @@ const { fileList, warnings } = await nodeFileTrace(entries, {
|
||||
// node-fetch optional peer for non-UTF-8 charset decoding; not
|
||||
// loaded in our usage.
|
||||
"encoding/**",
|
||||
// Host packages are added explicitly above; other optional native
|
||||
// variants are intentionally not part of the daemon closure.
|
||||
"@ff-labs/fff-bin-*/**",
|
||||
"@yuuang/ffi-rs-*/**",
|
||||
// Tests are stripped during the daemon build; nft sometimes still
|
||||
// tries to walk into them via index files. Belt and suspenders.
|
||||
"**/*.test.js",
|
||||
|
||||
Reference in New Issue
Block a user