diff --git a/packages/pi-fff/README.md b/packages/pi-fff/README.md index 9375c5ab..3157d953 100644 --- a/packages/pi-fff/README.md +++ b/packages/pi-fff/README.md @@ -162,6 +162,8 @@ All fields are optional: | `warnOnHomeDirScan` | boolean | `true` | | `followSymlinks` | boolean | `true` | +Starting a session in a directory the config opts out of indexing (`$HOME` with `enableHomeDirScanning: false`, `/` with `enableFsRootScanning: false`) disables FFF workspace search for that session: the extension reports it once as a warning and keeps the `ffgrep`/`fffind` names even in `override` mode, so pi's built-in `grep`/`find` stay reachable. + CLI flags take precedence over environment variables, which take precedence over this file. A missing file is ignored. Malformed JSON, unknown fields, and invalid values stop the extension from loading and report the file path and error. `/fff-mode` changes the current session; it does not edit this file. The file is global only. Project-level config cannot safely control tool names because pi decides which tools an extension registers before project configuration can be trusted. diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 75d93cfe..66fb5bcd 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -28,7 +28,7 @@ import { Type, type TSchema } from "@sinclair/typebox"; import { AuxFinderPool, routePathConstraint } from "./aux-finders"; import { type FffMode, loadConfig, VALID_MODES } from "./config"; import { FilePickerFactory } from "./file-picker"; -import { isHomeDir, resolveDbPaths } from "./paths"; +import { isFsRoot, isHomeDir, resolveDbPaths } from "./paths"; import { buildQuery } from "./query"; export { SCAN_TIMEOUT_MS } from "./sdk"; @@ -460,8 +460,22 @@ export default function fffExtension(pi: ExtensionAPI) { }); } + // The native layer refuses a picker rooted at $HOME / the fs root unless the + // matching opt-in is set (crates/fff-core/src/file_picker.rs). Detect that here + // so the opt-out reads as "search off" instead of an init failure (issue #857). + function scanOptOutReason(cwd: string): string | null { + if (!enableHomeDirScanning && isHomeDir(cwd)) + return `(fff): cwd is $HOME and "enableHomeDirScanning" is false, so FFF search is disabled for this session. Start pi from a project directory, or set "enableHomeDirScanning": true / --fff-enable-home-scan=true to index $HOME.`; + if (!enableFsRootScanning && isFsRoot(cwd)) + return `(fff): cwd is the filesystem root and "enableFsRootScanning" is false, so FFF search is disabled for this session. Start pi from a project directory, or set "enableFsRootScanning": true / --fff-enable-root-scan=true to index it.`; + return null; + } + // in case cwd changes we need to figure this out function ensureFinder(cwd: string): Promise { + const optOut = scanOptOutReason(cwd); + if (optOut) return Promise.reject(new Error(optOut)); + if (mainFinder && !mainFinder.isDestroyed && finderCwd === cwd) return Promise.resolve(mainFinder); @@ -741,6 +755,13 @@ export default function fffExtension(pi: ExtensionAPI) { } initializeFinderFactories(); + + // `override` replaces pi's built-in grep/find. With the cwd opted out of + // indexing that would leave the session without any working workspace + // search, so keep the FFF names and let the built-ins stand (issue #857). + if (currentMode === "override" && scanOptOutReason(activeCwd)) + toolNames = FFF_TOOL_NAMES; + registerPendingTools(); } @@ -748,6 +769,15 @@ export default function fffExtension(pi: ExtensionAPI) { try { prepareSession(ctx); registerAutocompleteProvider(ctx); + + // The user opted out of indexing this cwd, so skip the picker entirely + // instead of letting the native refusal surface as an error (issue #857). + const optOut = scanOptOutReason(activeCwd); + if (optOut) { + ctx.ui.notify(optOut, "warning"); + return; + } + await ensureFinder(activeCwd); // Warn when launched from $HOME with home scanning on: indexing a large diff --git a/packages/pi-fff/src/paths.ts b/packages/pi-fff/src/paths.ts index aa1b68e0..63f77254 100644 --- a/packages/pi-fff/src/paths.ts +++ b/packages/pi-fff/src/paths.ts @@ -18,6 +18,12 @@ export function isHomeDir(dir: string): boolean { return path.resolve(dir) === HOME_DIR; } +// Mirrors the native `path.parent().is_none()` check: a path that is its own parent. +export function isFsRoot(dir: string): boolean { + const resolved = path.resolve(dir); + return path.dirname(resolved) === resolved; +} + // Resolution order: explicit override > existing fff.nvim db > pi-local data dir. // Reusing the nvim db lets pi rank files by the frecency the user built in their editor. export function resolveDbPaths(overrides: { diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index e9d7fd58..adb3c180 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -67,10 +67,27 @@ function createMockFinder(): MockFinder { }; } +const NATIVE_ROOT_REFUSAL = + "Failed to init file picker: Can not run certain FFF features in a file system root or home directories. Consider smaller per-project directories."; + +// Mirrors the native guard in crates/fff-core/src/file_picker.rs: a picker rooted +// at $HOME or `/` is refused unless the matching opt-in is set. +function nativeRefusal(options: { + basePath: string; + enableHomeDirScanning?: boolean; + enableFsRootScanning?: boolean; +}): boolean { + const base = path.resolve(options.basePath); + if (options.enableHomeDirScanning === false && base === path.resolve(os.homedir())) + return true; + return options.enableFsRootScanning === false && path.dirname(base) === base; +} + const finderModule = { FileFinder: { - create: mock((options: unknown) => { + create: mock((options: any) => { createCalls.push(options); + if (nativeRefusal(options)) return { ok: false, error: NATIVE_ROOT_REFUSAL }; const finder = createMockFinder(); finders.push(finder); return { ok: true, value: finder }; @@ -452,12 +469,41 @@ describe("pi-fff $HOME scan warning", () => { expect(setup.ctx.ui.setStatus).toHaveBeenLastCalledWith("fff", undefined); }); - test("no warning when home scanning is disabled", async () => { + // #857: the opt-out must skip the picker, not surface the native refusal as an + // init error, and must not shadow pi's built-in search tools in override mode. + test("skips the picker and stays out of the way when home scanning is off", async () => { process.env.FFF_ENABLE_HOME_SCAN = "0"; - const setup = await start(undefined, os.homedir()); + const setup = await start("override", os.homedir()); - expect(setup.ctx.ui.notify).not.toHaveBeenCalled(); + expect(createCalls).toHaveLength(0); expect(setup.ctx.ui.setStatus).not.toHaveBeenCalled(); + + const [message, level] = setup.ctx.ui.notify.mock.calls[0]; + expect(setup.ctx.ui.notify).toHaveBeenCalledTimes(1); + expect(level).toBe("warning"); + expect(message).not.toContain("FFF init failed"); + expect(message).toContain("enableHomeDirScanning"); + + const toolNames = setup.pi.registerTool.mock.calls.map(([tool]) => tool.name); + expect(toolNames).toEqual(["ffgrep", "fffind"]); + await shutdown(setup); + }); + + test("skips the picker at the filesystem root without root scanning", async () => { + const setup = await start(undefined, path.parse(process.cwd()).root); + + expect(createCalls).toHaveLength(0); + const [message, level] = setup.ctx.ui.notify.mock.calls[0]; + expect(level).toBe("warning"); + expect(message).toContain("enableFsRootScanning"); + await shutdown(setup); + }); + + test("still indexes $HOME when the opt-out is not set", async () => { + const setup = await start(undefined, os.homedir()); + + expect(createCalls).toHaveLength(1); + expect((createCalls[0] as { basePath: string }).basePath).toBe(os.homedir()); await shutdown(setup); });