Compare commits

...

2 Commits

Author SHA1 Message Date
Mohamed Boudra
7c02b9ec7c fix(search): address workspace search review feedback 2026-07-01 19:07:53 +02:00
Mohamed Boudra
9905b0da31 fix(search): include dot-prefixed workspace entries
Workspace suggestions now use the indexed search backend directly, so dotfiles and dot directories are included without a second ignore layer. Packaged desktop smoke covers the native search path.
2026-06-30 23:46:32 +02:00
15 changed files with 1876 additions and 1161 deletions

View File

@@ -329,6 +329,18 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Windows arm64 native search packages
shell: bash
run: |
set -euo pipefail
fff_version="$(node -p "require('./node_modules/@ff-labs/fff-node/package.json').optionalDependencies['@ff-labs/fff-bin-win32-arm64']")"
ffi_version="$(node -p "require('./node_modules/ffi-rs/package.json').optionalDependencies['@yuuang/ffi-rs-win32-arm64-msvc']")"
npm install --no-save --package-lock=false --ignore-scripts --no-audit --fund=false --os=win32 --cpu=arm64 \
"@ff-labs/fff-bin-win32-arm64@${fff_version}" \
"@yuuang/ffi-rs-win32-arm64-msvc@${ffi_version}"
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set desktop package version from tag
shell: bash
run: |

View File

@@ -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
View File

@@ -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",

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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");

View File

@@ -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",

View File

@@ -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({

View 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);
}
}

View 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.
}
}

View File

@@ -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

View File

@@ -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",