feat(opencode): fff search tools (#27802)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
This commit is contained in:
committed by
GitHub
parent
4814ab3a3d
commit
7d3d80f840
@@ -265,6 +265,7 @@
|
|||||||
"@effect/opentelemetry": "catalog:",
|
"@effect/opentelemetry": "catalog:",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
|
"@ff-labs/fff-bun": "0.9.3",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@npmcli/config": "10.8.1",
|
"@npmcli/config": "10.8.1",
|
||||||
@@ -1446,6 +1447,24 @@
|
|||||||
|
|
||||||
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="],
|
||||||
|
|
||||||
|
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="],
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
exact = true
|
exact = true
|
||||||
# Only install newly resolved package versions published at least 3 days ago.
|
# Only install newly resolved package versions published at least 3 days ago.
|
||||||
minimumReleaseAge = 259200
|
minimumReleaseAge = 259200
|
||||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider"]
|
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"]
|
||||||
|
|
||||||
[test]
|
[test]
|
||||||
root = "./do-not-run-tests-from-root"
|
root = "./do-not-run-tests-from-root"
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
"bun": "./src/pty/pty.bun.ts",
|
"bun": "./src/pty/pty.bun.ts",
|
||||||
"node": "./src/pty/pty.node.ts",
|
"node": "./src/pty/pty.node.ts",
|
||||||
"default": "./src/pty/pty.bun.ts"
|
"default": "./src/pty/pty.bun.ts"
|
||||||
|
},
|
||||||
|
"#fff": {
|
||||||
|
"bun": "./src/filesystem/fff.bun.ts",
|
||||||
|
"node": "./src/filesystem/fff.node.ts",
|
||||||
|
"default": "./src/filesystem/fff.bun.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -81,6 +86,7 @@
|
|||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@effect/sql-sqlite-bun": "catalog:",
|
"@effect/sql-sqlite-bun": "catalog:",
|
||||||
"@lydell/node-pty": "catalog:",
|
"@lydell/node-pty": "catalog:",
|
||||||
|
"@ff-labs/fff-bun": "0.9.3",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@npmcli/config": "10.8.1",
|
"@npmcli/config": "10.8.1",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
FileFinder,
|
||||||
|
type DirItem,
|
||||||
|
type DirSearchResult,
|
||||||
|
type FileItem,
|
||||||
|
type GrepCursor,
|
||||||
|
type GrepMatch,
|
||||||
|
type GrepResult,
|
||||||
|
type InitOptions,
|
||||||
|
type MixedItem,
|
||||||
|
type MixedSearchResult,
|
||||||
|
type SearchResult,
|
||||||
|
} from "@ff-labs/fff-bun"
|
||||||
|
|
||||||
|
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||||
|
|
||||||
|
export type Init = InitOptions
|
||||||
|
|
||||||
|
export interface Search {
|
||||||
|
items: FileItem[]
|
||||||
|
scores: SearchResult["scores"]
|
||||||
|
totalMatched: number
|
||||||
|
totalFiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirSearch {
|
||||||
|
items: DirItem[]
|
||||||
|
scores: DirSearchResult["scores"]
|
||||||
|
totalMatched: number
|
||||||
|
totalDirs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixedSearch {
|
||||||
|
items: MixedItem[]
|
||||||
|
scores: MixedSearchResult["scores"]
|
||||||
|
totalMatched: number
|
||||||
|
totalFiles: number
|
||||||
|
totalDirs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type File = FileItem
|
||||||
|
export type Directory = DirItem
|
||||||
|
export type Mixed = MixedItem
|
||||||
|
export type Cursor = GrepCursor | null
|
||||||
|
export type Hit = GrepMatch
|
||||||
|
|
||||||
|
export interface Grep {
|
||||||
|
items: GrepResult["items"]
|
||||||
|
totalMatched: number
|
||||||
|
totalFilesSearched: number
|
||||||
|
totalFiles: number
|
||||||
|
filteredFileCount: number
|
||||||
|
nextCursor: Cursor
|
||||||
|
regexFallbackError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Picker {
|
||||||
|
destroy(): void
|
||||||
|
isScanning(): boolean
|
||||||
|
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
|
||||||
|
refreshGitStatus(): Result<number>
|
||||||
|
fileSearch(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<Search>
|
||||||
|
glob(
|
||||||
|
pattern: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<Search>
|
||||||
|
directorySearch(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<DirSearch>
|
||||||
|
mixedSearch(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<MixedSearch>
|
||||||
|
grep(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
mode?: "plain" | "regex" | "fuzzy"
|
||||||
|
maxMatchesPerFile?: number
|
||||||
|
timeBudgetMs?: number
|
||||||
|
beforeContext?: number
|
||||||
|
afterContext?: number
|
||||||
|
cursor?: Cursor
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<Grep>
|
||||||
|
trackQuery(query: string, file: string): Result<boolean>
|
||||||
|
getHistoricalQuery(offset: number): Result<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function available() {
|
||||||
|
return FileFinder.isAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function create(opts: Init): Result<Picker> {
|
||||||
|
const made = FileFinder.create(opts)
|
||||||
|
if (!made.ok) return made
|
||||||
|
const pick = made.value
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
destroy: () => pick.destroy(),
|
||||||
|
isScanning: () => pick.isScanning(),
|
||||||
|
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||||
|
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||||
|
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||||
|
glob: (pattern, next) => pick.glob(pattern, next),
|
||||||
|
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||||
|
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||||
|
grep: (query, next) => pick.grep(query, next),
|
||||||
|
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||||
|
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Fff from "./fff.bun"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||||
|
|
||||||
|
export interface Init {
|
||||||
|
basePath: string
|
||||||
|
frecencyDbPath?: string
|
||||||
|
historyDbPath?: string
|
||||||
|
useUnsafeNoLock?: boolean
|
||||||
|
disableMmapCache?: boolean
|
||||||
|
disableContentIndexing?: boolean
|
||||||
|
disableWatch?: boolean
|
||||||
|
aiMode?: boolean
|
||||||
|
logFilePath?: string
|
||||||
|
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
|
||||||
|
enableFsRootScanning?: boolean
|
||||||
|
enableHomeDirScanning?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface File {
|
||||||
|
relativePath: string
|
||||||
|
fileName: string
|
||||||
|
modified: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Directory {
|
||||||
|
relativePath: string
|
||||||
|
dirName: string
|
||||||
|
maxAccessFrecency: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||||
|
|
||||||
|
export interface Search {
|
||||||
|
items: File[]
|
||||||
|
scores: Array<{ total: number }>
|
||||||
|
totalMatched: number
|
||||||
|
totalFiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirSearch {
|
||||||
|
items: Directory[]
|
||||||
|
scores: Array<{ total: number }>
|
||||||
|
totalMatched: number
|
||||||
|
totalDirs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MixedSearch {
|
||||||
|
items: Mixed[]
|
||||||
|
scores: Array<{ total: number }>
|
||||||
|
totalMatched: number
|
||||||
|
totalFiles: number
|
||||||
|
totalDirs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Cursor = null
|
||||||
|
|
||||||
|
export interface Hit {
|
||||||
|
relativePath: string
|
||||||
|
fileName: string
|
||||||
|
lineNumber: number
|
||||||
|
byteOffset: number
|
||||||
|
lineContent: string
|
||||||
|
matchRanges: [number, number][]
|
||||||
|
contextBefore?: string[]
|
||||||
|
contextAfter?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Grep {
|
||||||
|
items: Hit[]
|
||||||
|
totalMatched: number
|
||||||
|
totalFilesSearched: number
|
||||||
|
totalFiles: number
|
||||||
|
filteredFileCount: number
|
||||||
|
nextCursor: Cursor
|
||||||
|
regexFallbackError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Picker {
|
||||||
|
destroy(): void
|
||||||
|
isScanning(): boolean
|
||||||
|
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
|
||||||
|
refreshGitStatus(): Result<number>
|
||||||
|
fileSearch(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<Search>
|
||||||
|
glob(
|
||||||
|
pattern: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<Search>
|
||||||
|
directorySearch(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<DirSearch>
|
||||||
|
mixedSearch(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
currentFile?: string
|
||||||
|
pageIndex?: number
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<MixedSearch>
|
||||||
|
grep(
|
||||||
|
query: string,
|
||||||
|
opts?: {
|
||||||
|
mode?: "plain" | "regex" | "fuzzy"
|
||||||
|
maxMatchesPerFile?: number
|
||||||
|
timeBudgetMs?: number
|
||||||
|
beforeContext?: number
|
||||||
|
afterContext?: number
|
||||||
|
cursor?: Cursor
|
||||||
|
pageSize?: number
|
||||||
|
},
|
||||||
|
): Result<Grep>
|
||||||
|
trackQuery(query: string, file: string): Result<boolean>
|
||||||
|
getHistoricalQuery(offset: number): Result<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function available() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function create(_opts: Init): Result<Picker> {
|
||||||
|
return { ok: false, error: "fff unavailable on node runtime" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Fff from "./fff.node"
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import path from "path"
|
||||||
|
import { Context, Deferred, Effect, Layer, Option, Stream } from "effect"
|
||||||
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Glob } from "../util/glob"
|
||||||
|
import { Global } from "../global"
|
||||||
|
import * as Log from "../util/log"
|
||||||
|
import { serviceUse } from "../effect/service-use"
|
||||||
|
import { makeRuntime } from "../effect/runtime"
|
||||||
|
import { Fff } from "#fff"
|
||||||
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "file.search" })
|
||||||
|
const root = path.join(Global.Path.cache, "fff")
|
||||||
|
|
||||||
|
export type Item = Ripgrep.Item
|
||||||
|
export type SearchError = PlatformError | globalThis.Error
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly items: Item[]
|
||||||
|
readonly partial: boolean
|
||||||
|
readonly hasNextPage: boolean
|
||||||
|
readonly engine: "fff" | "ripgrep"
|
||||||
|
readonly regexFallbackError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInput {
|
||||||
|
readonly cwd: string
|
||||||
|
readonly query: string
|
||||||
|
readonly limit?: number
|
||||||
|
readonly current?: string
|
||||||
|
readonly kind?: "file" | "directory" | "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobInput {
|
||||||
|
readonly cwd: string
|
||||||
|
readonly pattern: string
|
||||||
|
readonly limit?: number
|
||||||
|
readonly signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Query {
|
||||||
|
readonly dir: string
|
||||||
|
readonly text: string
|
||||||
|
readonly files: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A created picker plus its cached scan-readiness gate. The picker is created
|
||||||
|
// (and its native background scan kicked off) eagerly; `ready` is only awaited
|
||||||
|
// when the picker is actually used.
|
||||||
|
interface Picker {
|
||||||
|
readonly pick: Fff.Picker
|
||||||
|
readonly ready: Effect.Effect<void, Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
readonly pick: Map<string, Picker>
|
||||||
|
readonly wait: Map<string, Deferred.Deferred<Picker, Error>>
|
||||||
|
readonly recent: Query[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly files: Ripgrep.Interface["files"]
|
||||||
|
readonly tree: Ripgrep.Interface["tree"]
|
||||||
|
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
|
||||||
|
readonly file: (input: FileInput) => Effect.Effect<string[] | undefined, SearchError>
|
||||||
|
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
|
||||||
|
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
|
||||||
|
readonly warm: (cwd: string) => Effect.Effect<void>
|
||||||
|
// Destroy the picker for a directory and drop its cached state. Called when a
|
||||||
|
// directory's instance is disposed so fff's native watcher thread is torn
|
||||||
|
// down instead of leaking until process exit.
|
||||||
|
readonly release: (cwd: string) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
|
||||||
|
|
||||||
|
export const use = serviceUse(Service)
|
||||||
|
|
||||||
|
function key(dir: string) {
|
||||||
|
return Buffer.from(dir).toString("base64url")
|
||||||
|
}
|
||||||
|
|
||||||
|
function fffSync<A>(action: string, run: () => A) {
|
||||||
|
return Effect.try({
|
||||||
|
try: run,
|
||||||
|
catch: (cause) => new Error(`fff ${action} failed`, { cause }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(text: string) {
|
||||||
|
return text.replaceAll("\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fff supports glob narrowing for any search out of the box
|
||||||
|
function fffGlobbedQuery(query: string, glob?: string | string[]) {
|
||||||
|
if (query && glob) {
|
||||||
|
const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
|
||||||
|
return `${resolvedGlob} ${query}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return query ?? glob
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(state: State, dir: string, text: string, files: string[]) {
|
||||||
|
if (!files.length) return
|
||||||
|
const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64)
|
||||||
|
if (!next.length) return
|
||||||
|
const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
|
||||||
|
if (idx >= 0) state.recent.splice(idx, 1)
|
||||||
|
state.recent.unshift({ dir, text, files: next })
|
||||||
|
if (state.recent.length > 32) state.recent.length = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(hit: Fff.Hit): Item {
|
||||||
|
const line = Buffer.from(hit.lineContent)
|
||||||
|
return {
|
||||||
|
path: { text: normalize(hit.relativePath) },
|
||||||
|
lines: { text: hit.lineContent },
|
||||||
|
line_number: hit.lineNumber,
|
||||||
|
absolute_offset: hit.byteOffset,
|
||||||
|
submatches: hit.matchRanges
|
||||||
|
.map(([start, end]) => {
|
||||||
|
const text = line.subarray(start, end).toString("utf8")
|
||||||
|
if (!text) return undefined
|
||||||
|
return {
|
||||||
|
match: { text },
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((row): row is Item["submatches"][number] => Boolean(row)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPaths<T>(
|
||||||
|
out: { items: T[]; scores: Array<{ total: number }> },
|
||||||
|
toPath: (item: T) => string,
|
||||||
|
opts?: { includeZeroScore?: boolean },
|
||||||
|
): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
out.items.flatMap((item, idx): string[] => {
|
||||||
|
const score = out.scores[idx]
|
||||||
|
if (!score || (!opts?.includeZeroScore && score.total <= 0)) return []
|
||||||
|
const text = toPath(item)
|
||||||
|
if (!text) return []
|
||||||
|
return [text]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchFff(
|
||||||
|
pick: Fff.Picker,
|
||||||
|
kind: "file" | "directory" | "all",
|
||||||
|
query: string,
|
||||||
|
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
|
||||||
|
): Fff.Result<string[]> {
|
||||||
|
if (kind === "directory") {
|
||||||
|
const out = pick.directorySearch(query, opts)
|
||||||
|
if (!out.ok) return out
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kind === "all") {
|
||||||
|
const out = pick.mixedSearch(query, opts)
|
||||||
|
if (!out.ok) return out
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = pick.fileSearch(query, opts)
|
||||||
|
if (!out.ok) return out
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
|
||||||
|
const state: State = {
|
||||||
|
pick: new Map<string, Picker>(),
|
||||||
|
wait: new Map<string, Deferred.Deferred<Picker, Error>>(),
|
||||||
|
recent: [] as Query[],
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* fs.ensureDir(root).pipe(Effect.ignore)
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.forEach(
|
||||||
|
state.pick.values(),
|
||||||
|
(entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore),
|
||||||
|
{ discard: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
|
||||||
|
const out = yield* rg.search(input)
|
||||||
|
return {
|
||||||
|
items: out.items,
|
||||||
|
partial: out.partial,
|
||||||
|
hasNextPage: false,
|
||||||
|
engine: "ripgrep" as const,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lazy, shared scan-wait for a picker. Preserves the original behavior: if
|
||||||
|
// the scan does not finish within the budget the picker is destroyed and
|
||||||
|
// dropped from the cache so callers fall back to ripgrep (and the next
|
||||||
|
// request recreates a fresh picker).
|
||||||
|
const scanReady = (dir: string, pick: Fff.Picker) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scanned = yield* Effect.tryPromise({
|
||||||
|
try: () => pick.waitForScan(5_000),
|
||||||
|
catch: (cause) => new Error("fff waitForScan failed", { cause }),
|
||||||
|
})
|
||||||
|
if (!scanned.ok || !scanned.value) {
|
||||||
|
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
|
||||||
|
state.pick.delete(dir)
|
||||||
|
log.warn("fff scan not ready", { dir })
|
||||||
|
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
|
||||||
|
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create (or return) the picker for a directory. Creation is synchronous
|
||||||
|
// and does not await the scan; the native background scan starts as soon as
|
||||||
|
// the picker exists. The `wait` gate dedupes concurrent creation.
|
||||||
|
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
|
||||||
|
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
log.warn("fff availability check failed", { error })
|
||||||
|
return Effect.succeed(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!available) return undefined
|
||||||
|
|
||||||
|
const dir = FSUtil.resolve(cwd)
|
||||||
|
const existing = state.pick.get(dir)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const pending = state.wait.get(dir)
|
||||||
|
if (pending) return yield* Deferred.await(pending)
|
||||||
|
|
||||||
|
const gate = yield* Deferred.make<Picker, Error>()
|
||||||
|
state.wait.set(dir, gate)
|
||||||
|
return yield* Effect.gen(function* () {
|
||||||
|
const id = key(dir)
|
||||||
|
const isFirstPicker = state.pick.size === 0
|
||||||
|
const made = yield* fffSync("create picker", () =>
|
||||||
|
Fff.create({
|
||||||
|
basePath: dir,
|
||||||
|
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
|
||||||
|
historyDbPath: path.join(root, `${id}.history.mdb`),
|
||||||
|
// fff uses a bit different log version, also with spans so keep
|
||||||
|
// them in the same folder for debuggability
|
||||||
|
logFilePath: path.join(Global.Path.log, "fff.log"),
|
||||||
|
logLevel: Log.getLevel().toLowerCase() as Lowercase<Log.Level>,
|
||||||
|
aiMode: true,
|
||||||
|
// only the first toolcall picker can accumulate resources to index
|
||||||
|
// home directory, if the user specifically opened opencode at the
|
||||||
|
// $HOME level or asked it to search there on purpose, otherwise fallback
|
||||||
|
enableHomeDirScanning: isFirstPicker,
|
||||||
|
// on unix system it is 99.9% that you do not need to search for the
|
||||||
|
// content at the / so make fff fail creation and fallback to rg
|
||||||
|
enableFsRootScanning: isFirstPicker && process.platform === "win32",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!made.ok) {
|
||||||
|
log.warn("fff init failed", { dir, error: made.error })
|
||||||
|
const err = new Error(made.error)
|
||||||
|
yield* Deferred.fail(gate, err)
|
||||||
|
return yield* Effect.fail(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = made.value
|
||||||
|
const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) }
|
||||||
|
state.pick.set(dir, entry)
|
||||||
|
yield* Deferred.succeed(gate, entry)
|
||||||
|
return entry
|
||||||
|
}).pipe(
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (state.wait.get(dir) === gate) state.wait.delete(dir)
|
||||||
|
yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resolve a usable, scanned picker for a directory, or undefined when fff is
|
||||||
|
// unavailable or the scan did not become ready.
|
||||||
|
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
|
||||||
|
const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
|
||||||
|
if (!entry) return undefined
|
||||||
|
const ready = yield* entry.ready.pipe(
|
||||||
|
Effect.as(true),
|
||||||
|
Effect.catch(() => Effect.succeed(false)),
|
||||||
|
)
|
||||||
|
if (!ready) return undefined
|
||||||
|
return entry.pick
|
||||||
|
})
|
||||||
|
|
||||||
|
const files: Interface["files"] = (input) => rg.files(input)
|
||||||
|
const tree: Interface["tree"] = (input) => rg.tree(input)
|
||||||
|
|
||||||
|
// in 99% of use cases user that is opened opencode at certain directory will
|
||||||
|
// conduct a file search in this direcotry, it could be switched later but
|
||||||
|
// mostly always we will need a file picker for cwd
|
||||||
|
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
|
||||||
|
const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) {
|
||||||
|
yield* acquire(cwd).pipe(Effect.ignore)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tear down the picker for a directory. fff pickers own a native background
|
||||||
|
// watcher thread that otherwise lives until the runtime scope closes (i.e.
|
||||||
|
// process exit), so disposing the instance that warmed it must destroy it
|
||||||
|
// here or the thread leaks against a directory that may already be gone.
|
||||||
|
const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) {
|
||||||
|
const dir = FSUtil.resolve(cwd)
|
||||||
|
|
||||||
|
const pending = state.wait.get(dir)
|
||||||
|
if (pending) {
|
||||||
|
state.wait.delete(dir)
|
||||||
|
yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = state.pick.get(dir)
|
||||||
|
if (entry) {
|
||||||
|
state.pick.delete(dir)
|
||||||
|
yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = state.recent.filter((item) => item.dir !== dir)
|
||||||
|
state.recent.splice(0, state.recent.length, ...remaining)
|
||||||
|
})
|
||||||
|
|
||||||
|
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
|
||||||
|
const query = input.query.trim()
|
||||||
|
const kind = input.kind ?? "file"
|
||||||
|
|
||||||
|
const pick = yield* picker(input.cwd)
|
||||||
|
if (!pick) return undefined
|
||||||
|
|
||||||
|
const dir = FSUtil.resolve(input.cwd)
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
const fffResult = yield* fffSync(`${kind} search`, () =>
|
||||||
|
searchFff(pick, kind, query, {
|
||||||
|
pageIndex: 0,
|
||||||
|
currentFile: input.current, // supports both relative and absolute (relative preferred)
|
||||||
|
pageSize: limit,
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
log.warn(`fff ${kind} search failed`, { dir, query, error })
|
||||||
|
return Effect.succeed<Fff.Result<string[]> | undefined>(undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!fffResult) return undefined
|
||||||
|
if (!fffResult.ok) {
|
||||||
|
log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = fffResult.value
|
||||||
|
remember(
|
||||||
|
state,
|
||||||
|
dir,
|
||||||
|
query,
|
||||||
|
rows.map((row) => path.join(dir, row)),
|
||||||
|
)
|
||||||
|
return rows.slice(0, limit)
|
||||||
|
})
|
||||||
|
|
||||||
|
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
if (input.file?.length) return yield* rip(input)
|
||||||
|
|
||||||
|
const pick = yield* picker(input.cwd)
|
||||||
|
if (!pick) return yield* rip(input)
|
||||||
|
|
||||||
|
const dir = FSUtil.resolve(input.cwd)
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
|
||||||
|
const fffGrep = yield* fffSync("grep", () =>
|
||||||
|
pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
|
||||||
|
mode: "regex",
|
||||||
|
pageSize: limit,
|
||||||
|
timeBudgetMs: 1_500,
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
log.warn("fff grep failed", { dir, pattern: input.pattern, error })
|
||||||
|
return Effect.succeed<Fff.Result<Fff.Grep> | undefined>(undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!fffGrep) return yield* rip(input)
|
||||||
|
if (!fffGrep.ok) {
|
||||||
|
log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
|
||||||
|
return yield* rip(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Item[] = fffGrep.value.items.map(item)
|
||||||
|
const regexFallbackError = fffGrep.value.regexFallbackError
|
||||||
|
|
||||||
|
remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows,
|
||||||
|
partial: false,
|
||||||
|
hasNextPage: !!fffGrep.value.nextCursor,
|
||||||
|
engine: "fff" as const,
|
||||||
|
regexFallbackError,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
const dir = FSUtil.resolve(input.cwd)
|
||||||
|
const limit = input.limit ?? 100
|
||||||
|
const pick = yield* picker(dir)
|
||||||
|
|
||||||
|
if (pick) {
|
||||||
|
const fffGlob = yield* fffSync("glob file search", () =>
|
||||||
|
pick.glob(normalize(input.pattern), {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: limit,
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
log.warn("fff glob failed", { dir, pattern: input.pattern, error })
|
||||||
|
return Effect.succeed<Fff.Result<Fff.Search> | undefined>(undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fffGlob?.ok) {
|
||||||
|
const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath))))
|
||||||
|
|
||||||
|
remember(
|
||||||
|
state,
|
||||||
|
dir,
|
||||||
|
input.pattern,
|
||||||
|
rows.map((row) => path.join(dir, row)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
|
||||||
|
truncated: fffGlob.value.totalMatched > rows.length,
|
||||||
|
}
|
||||||
|
} else if (fffGlob) {
|
||||||
|
log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
|
||||||
|
// fall through to the fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
|
||||||
|
Stream.take(limit + 1),
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.map((chunk) => [...chunk]),
|
||||||
|
)
|
||||||
|
const truncated = rows.length > limit
|
||||||
|
if (truncated) rows.length = limit
|
||||||
|
|
||||||
|
const output = yield* Effect.forEach(
|
||||||
|
rows,
|
||||||
|
Effect.fnUntraced(function* (file) {
|
||||||
|
const full = path.join(dir, file)
|
||||||
|
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
const time =
|
||||||
|
info?.mtime.pipe(
|
||||||
|
Option.map((item) => item.getTime()),
|
||||||
|
Option.getOrElse(() => 0),
|
||||||
|
) ?? 0
|
||||||
|
return { file: full, time }
|
||||||
|
}),
|
||||||
|
{ concurrency: 16 },
|
||||||
|
)
|
||||||
|
output.sort((a, b) => b.time - a.time)
|
||||||
|
return {
|
||||||
|
files: output.map((item) => item.file),
|
||||||
|
truncated,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
|
||||||
|
const file = input.cwd
|
||||||
|
? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
|
||||||
|
: FSUtil.resolve(input.file)
|
||||||
|
const idx = state.recent.findIndex((item) => item.files.includes(file))
|
||||||
|
if (idx < 0) return
|
||||||
|
|
||||||
|
const row = state.recent[idx]
|
||||||
|
state.recent.splice(idx, 1)
|
||||||
|
const entry = state.pick.get(row.dir)
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
|
||||||
|
Effect.catch((error) => {
|
||||||
|
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error })
|
||||||
|
return Effect.succeed<Fff.Result<boolean> | undefined>(undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!out) return
|
||||||
|
if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ files, tree, search, file, glob, open, warm, release })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export function tree(input: Ripgrep.TreeInput) {
|
||||||
|
return runPromise((svc) => svc.tree(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function search(input: Ripgrep.SearchInput) {
|
||||||
|
return runPromise((svc) => svc.search(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function file(input: FileInput) {
|
||||||
|
return runPromise((svc) => svc.file(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function glob(input: GlobInput) {
|
||||||
|
return runPromise((svc) => svc.glob(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function open(input: { cwd?: string; file: string }) {
|
||||||
|
return runPromise((svc) => svc.open(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as Search from "./search"
|
||||||
@@ -58,6 +58,9 @@ let logpath = ""
|
|||||||
export function file() {
|
export function file() {
|
||||||
return logpath
|
return logpath
|
||||||
}
|
}
|
||||||
|
export function getLevel(): Level {
|
||||||
|
return level
|
||||||
|
}
|
||||||
let write = (msg: any) => {
|
let write = (msg: any) => {
|
||||||
process.stderr.write(msg)
|
process.stderr.write(msg)
|
||||||
return msg.length
|
return msg.length
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Fff } from "#fff"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Search.defaultLayer)
|
||||||
|
|
||||||
|
const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
|
||||||
|
(dir) =>
|
||||||
|
Effect.promise(() =>
|
||||||
|
fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||||
|
).pipe(Effect.ignore),
|
||||||
|
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
|
||||||
|
|
||||||
|
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
|
||||||
|
|
||||||
|
describe("file.search", () => {
|
||||||
|
it.live("uses fff for Bun-backed grep", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
|
||||||
|
|
||||||
|
expect(result.engine).toBe("fff")
|
||||||
|
expect(result.items).toHaveLength(1)
|
||||||
|
expect(result.items[0]?.path.text).toBe("src/match.ts")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("keeps fuzzy file abbreviation matches", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "README.md"), "hello\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
|
||||||
|
|
||||||
|
expect(results).toContain("README.md")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("keeps empty file query candidates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "README.md"), "hello\n")
|
||||||
|
yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
|
||||||
|
|
||||||
|
expect(results).toContain("README.md")
|
||||||
|
expect(results).toContain("src/")
|
||||||
|
expect(results).not.toContain("")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("keeps paging grep results without an explicit limit", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(
|
||||||
|
path.join(dir, "matches.txt"),
|
||||||
|
Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""),
|
||||||
|
)
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const result = yield* search.search({ cwd: dir, pattern: "needle" })
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(150)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("uses byte ranges for UTF-8 grep submatches", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "unicode.txt"), "éneedle\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
|
||||||
|
|
||||||
|
expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("post-filters fff grep include matches", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
|
||||||
|
yield* write(path.join(dir, "src", "match.txt"), "needle\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
|
||||||
|
|
||||||
|
expect(result.engine).toBe("fff")
|
||||||
|
expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("keeps fff grep include no-match results", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "src", "match.ts"), "needle\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 })
|
||||||
|
|
||||||
|
expect(result.engine).toBe("fff")
|
||||||
|
expect(result.items).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("post-filters fff glob matches", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n")
|
||||||
|
yield* write(path.join(dir, "src", "match.txt"), "hello\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
|
||||||
|
|
||||||
|
expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("tracks an opened file against its originating query", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(Fff.available()).toBe(true)
|
||||||
|
const dir = yield* tmpdir()
|
||||||
|
yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")
|
||||||
|
yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
|
||||||
|
|
||||||
|
const search = yield* Search.Service
|
||||||
|
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
|
||||||
|
expect(results).toContain("alpha-target-two.ts")
|
||||||
|
|
||||||
|
// open() records the query->file association in fff's history db via the
|
||||||
|
// live picker. It must resolve a remembered file and run without error.
|
||||||
|
yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
|
|
||||||
|
const dir = process.cwd()
|
||||||
|
|
||||||
|
const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
|
||||||
|
const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
|
||||||
|
const GLOB_QUERIES = ["**/*.test.ts"]
|
||||||
|
|
||||||
|
const FILE_LIMIT = 100
|
||||||
|
const GREP_LIMIT = 50
|
||||||
|
const GLOB_LIMIT = 50
|
||||||
|
|
||||||
|
const run = <A>(effect: Effect.Effect<A, unknown, Search.Service>) =>
|
||||||
|
AppRuntime.runPromise(
|
||||||
|
InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
|
||||||
|
) as Promise<A>
|
||||||
|
|
||||||
|
// --- raw Fff picker ---
|
||||||
|
const t0 = performance.now()
|
||||||
|
const made = Fff.create({ basePath: dir, aiMode: true })
|
||||||
|
if (!made.ok) {
|
||||||
|
console.error("Fff.create failed:", made.error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const picker = made.value
|
||||||
|
console.log(`picker create: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||||
|
|
||||||
|
const tw = performance.now()
|
||||||
|
await picker.waitForScan(2_500)
|
||||||
|
console.log(`wait for scan: ${(performance.now() - tw).toFixed(1)}ms`)
|
||||||
|
|
||||||
|
// warmup grep to let the content index build
|
||||||
|
const tWarmup = performance.now()
|
||||||
|
picker.grep("_warmup_", { mode: "regex", maxMatchesPerFile: 1, timeBudgetMs: 1_500 })
|
||||||
|
console.log(`grep warmup: ${(performance.now() - tWarmup).toFixed(1)}ms`)
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log("--- raw picker (warm) ---")
|
||||||
|
|
||||||
|
for (const q of FILE_QUERIES) {
|
||||||
|
const t = performance.now()
|
||||||
|
const r = picker.fileSearch(q, { pageSize: Math.max(FILE_LIMIT, 100) })
|
||||||
|
const count = r.ok ? r.value.items.length : "err"
|
||||||
|
console.log(`[picker] fileSearch "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} results)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const q of GREP_QUERIES) {
|
||||||
|
const t = performance.now()
|
||||||
|
const r = picker.grep(q, { mode: "regex", pageSize: GREP_LIMIT, timeBudgetMs: 1_500 })
|
||||||
|
const count = r.ok ? r.value.items.length : "err"
|
||||||
|
console.log(`[picker] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${count} matches)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.destroy()
|
||||||
|
|
||||||
|
// --- Ripgrep service (via Search with file:["."] to force rg path) ---
|
||||||
|
console.log()
|
||||||
|
console.log("--- Ripgrep (via Search service) ---")
|
||||||
|
|
||||||
|
// warmup
|
||||||
|
await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] })))
|
||||||
|
|
||||||
|
for (const q of GREP_QUERIES) {
|
||||||
|
const t = performance.now()
|
||||||
|
const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] })))
|
||||||
|
console.log(
|
||||||
|
`[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search service: init breakdown ---
|
||||||
|
console.log()
|
||||||
|
|
||||||
|
// 1) runtime + InstanceState + picker create + scan poll
|
||||||
|
const tRuntime = performance.now()
|
||||||
|
await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 })))
|
||||||
|
console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
|
||||||
|
|
||||||
|
// 2) grep warmup (content index cold-start inside the Search service picker)
|
||||||
|
const tGrepWarmup = performance.now()
|
||||||
|
await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 })))
|
||||||
|
console.log(`[Search] init grep (content index warmup): ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
console.log("--- Search service (warm) ---")
|
||||||
|
|
||||||
|
for (const q of FILE_QUERIES) {
|
||||||
|
const t = performance.now()
|
||||||
|
const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT })))
|
||||||
|
console.log(
|
||||||
|
`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r?.length ?? "undefined (cache fallback)"} results)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const q of GREP_QUERIES) {
|
||||||
|
const t = performance.now()
|
||||||
|
const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT })))
|
||||||
|
console.log(
|
||||||
|
`[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const q of GLOB_QUERIES) {
|
||||||
|
const t = performance.now()
|
||||||
|
const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT })))
|
||||||
|
console.log(
|
||||||
|
`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import { EOL } from "os"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
@@ -68,7 +68,7 @@ const FileTreeCommand = effectCmd({
|
|||||||
default: process.cwd(),
|
default: process.cwd(),
|
||||||
}),
|
}),
|
||||||
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
|
handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
|
||||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
|
||||||
console.log(JSON.stringify(tree, null, 2))
|
console.log(JSON.stringify(tree, null, 2))
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { effectCmd } from "../../effect-cmd"
|
import { effectCmd } from "../../effect-cmd"
|
||||||
import { cmd } from "../cmd"
|
import { cmd } from "../cmd"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
@@ -22,7 +22,7 @@ const TreeCommand = effectCmd({
|
|||||||
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
|
handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
|
||||||
const ctx = yield* InstanceRef
|
const ctx = yield* InstanceRef
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
|
const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
|
||||||
process.stdout.write(tree + EOL)
|
process.stdout.write(tree + EOL)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -47,8 +47,8 @@ const FilesCommand = effectCmd({
|
|||||||
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
|
||||||
const ctx = yield* InstanceRef
|
const ctx = yield* InstanceRef
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
const rg = yield* Ripgrep.Service
|
const search = yield* Search.Service
|
||||||
const files = yield* rg
|
const files = yield* search
|
||||||
.files({
|
.files({
|
||||||
cwd: ctx.directory,
|
cwd: ctx.directory,
|
||||||
glob: args.glob ? [args.glob] : undefined,
|
glob: args.glob ? [args.glob] : undefined,
|
||||||
@@ -85,7 +85,7 @@ const SearchCommand = effectCmd({
|
|||||||
const ctx = yield* InstanceRef
|
const ctx = yield* InstanceRef
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
const results = yield* Effect.orDie(
|
const results = yield* Effect.orDie(
|
||||||
Ripgrep.Service.use((svc) =>
|
Search.Service.use((svc) =>
|
||||||
svc.search({
|
svc.search({
|
||||||
cwd: ctx.directory,
|
cwd: ctx.directory,
|
||||||
pattern: args.pattern,
|
pattern: args.pattern,
|
||||||
|
|||||||
@@ -345,21 +345,12 @@ export function Autocomplete(props: {
|
|||||||
|
|
||||||
const options: AutocompleteOption[] = []
|
const options: AutocompleteOption[] = []
|
||||||
|
|
||||||
// Add file options
|
// Add file options. Trust the order returned by fff (frecency, fuzzy
|
||||||
|
// score, filename bonus, etc. are already factored in).
|
||||||
if (!result.error && result.data) {
|
if (!result.error && result.data) {
|
||||||
const sortedFiles = result.data.sort((a, b) => {
|
|
||||||
const aScore = frecency.getFrecency(a)
|
|
||||||
const bScore = frecency.getFrecency(b)
|
|
||||||
if (aScore !== bScore) return bScore - aScore
|
|
||||||
const aDepth = a.split("/").length
|
|
||||||
const bDepth = b.split("/").length
|
|
||||||
if (aDepth !== bDepth) return aDepth - bDepth
|
|
||||||
return a.localeCompare(b)
|
|
||||||
})
|
|
||||||
|
|
||||||
const width = props.anchor().width - 4
|
const width = props.anchor().width - 4
|
||||||
options.push(
|
options.push(
|
||||||
...sortedFiles.map((item): AutocompleteOption => {
|
...result.data.map((item): AutocompleteOption => {
|
||||||
const { filename, url, part } = createFilePart(item, lineRange)
|
const { filename, url, part } = createFilePart(item, lineRange)
|
||||||
|
|
||||||
const isDir = item.endsWith("/")
|
const isDir = item.endsWith("/")
|
||||||
@@ -506,45 +497,49 @@ export function Autocomplete(props: {
|
|||||||
const agentsValue = agents()
|
const agentsValue = agents()
|
||||||
const referenceAliasesValue = referenceAliases()
|
const referenceAliasesValue = referenceAliases()
|
||||||
const commandsValue = commands()
|
const commandsValue = commands()
|
||||||
|
|
||||||
const mixed: AutocompleteOption[] =
|
|
||||||
store.visible === "@"
|
|
||||||
? referenceMatchValue
|
|
||||||
? referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
|
||||||
: [...referenceAliasesValue, ...agentsValue, ...(filesValue || []), ...mcpResources()]
|
|
||||||
: [...commandsValue]
|
|
||||||
|
|
||||||
const searchValue = search()
|
const searchValue = search()
|
||||||
|
|
||||||
|
// @<alias>/... — narrow to the matched reference, files come from fff
|
||||||
|
// already ranked so there is no re-ranking here.
|
||||||
|
if (store.visible === "@" && referenceMatchValue) {
|
||||||
|
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files come from fff already fuzzy ranked and filtered
|
||||||
|
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
|
||||||
|
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
|
||||||
|
const nonFileOptions: AutocompleteOption[] =
|
||||||
|
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
|
||||||
|
|
||||||
if (!searchValue) {
|
if (!searchValue) {
|
||||||
return mixed
|
return [...nonFileOptions, ...fileOptions]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.loading && prev && prev.length > 0) {
|
if (files.loading && prev && prev.length > 0) {
|
||||||
return prev
|
return prev
|
||||||
}
|
}
|
||||||
|
|
||||||
if (referenceMatchValue) return mixed
|
const fuzziedNonFiles = fuzzysort
|
||||||
|
.go(removeLineRange(searchValue), nonFileOptions, {
|
||||||
|
keys: [
|
||||||
|
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
||||||
|
"description",
|
||||||
|
(obj) => obj.aliases?.join(" ") ?? "",
|
||||||
|
],
|
||||||
|
limit: 10,
|
||||||
|
scoreFn: (objResults) => {
|
||||||
|
const displayResult = objResults[0]
|
||||||
|
let score = objResults.score
|
||||||
|
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
|
||||||
|
score *= 2
|
||||||
|
}
|
||||||
|
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
|
||||||
|
return score * (1 + frecencyScore)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.map((arr) => arr.obj)
|
||||||
|
|
||||||
const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
|
return [...fuzziedNonFiles, ...fileOptions].slice(0, 10)
|
||||||
keys: [
|
|
||||||
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
|
|
||||||
"description",
|
|
||||||
(obj) => obj.aliases?.join(" ") ?? "",
|
|
||||||
],
|
|
||||||
limit: 10,
|
|
||||||
scoreFn: (objResults) => {
|
|
||||||
const displayResult = objResults[0]
|
|
||||||
let score = objResults.score
|
|
||||||
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
|
|
||||||
score *= 2
|
|
||||||
}
|
|
||||||
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
|
|
||||||
return score * (1 + frecencyScore)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.map((arr) => arr.obj)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Account } from "@/account/account"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { Storage } from "@/storage/storage"
|
import { Storage } from "@/storage/storage"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
@@ -62,6 +63,7 @@ export const AppLayer = Layer.mergeAll(
|
|||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Ripgrep.defaultLayer,
|
||||||
|
Search.defaultLayer,
|
||||||
Storage.defaultLayer,
|
Storage.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { Snapshot } from "../snapshot"
|
|||||||
import * as Project from "./project"
|
import * as Project from "./project"
|
||||||
import * as Vcs from "./vcs"
|
import * as Vcs from "./vcs"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { registerDisposer } from "@/effect/instance-registry"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Service } from "./bootstrap-service"
|
import { Service } from "./bootstrap-service"
|
||||||
@@ -26,15 +28,25 @@ export const layer = Layer.effect(
|
|||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const project = yield* Project.Service
|
const project = yield* Project.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
const shareNext = yield* ShareNext.Service
|
const shareNext = yield* ShareNext.Service
|
||||||
const snapshot = yield* Snapshot.Service
|
const snapshot = yield* Snapshot.Service
|
||||||
const vcs = yield* Vcs.Service
|
const vcs = yield* Vcs.Service
|
||||||
|
|
||||||
|
// once we dispose the service - also release all the internal fff resources
|
||||||
|
const off = registerDisposer((directory) => Effect.runPromise(search.release(directory)))
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||||
|
|
||||||
const run = Effect.gen(function* () {
|
const run = Effect.gen(function* () {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))
|
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory))
|
||||||
// everything depends on config so eager load it for nice traces
|
// everything depends on config so eager load it for nice traces
|
||||||
yield* config.get()
|
yield* config.get()
|
||||||
|
// in 99% of use cases user that is opened opencode at certain directory will
|
||||||
|
// conduct a file search in this direcotry, it could be switched later but
|
||||||
|
// mostly always we will need a file picker for cwd
|
||||||
|
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
|
||||||
|
yield* search.warm(ctx.directory).pipe(Effect.ignore)
|
||||||
// Plugin can mutate config so it has to be initialized before anything else.
|
// Plugin can mutate config so it has to be initialized before anything else.
|
||||||
yield* plugin.init()
|
yield* plugin.init()
|
||||||
// Each service self-manages its own slow work via Effect.forkScoped against
|
// Each service self-manages its own slow work via Effect.forkScoped against
|
||||||
@@ -58,6 +70,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
|||||||
Plugin.defaultLayer,
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Reference.defaultLayer,
|
Reference.defaultLayer,
|
||||||
|
Search.defaultLayer,
|
||||||
ShareNext.defaultLayer,
|
ShareNext.defaultLayer,
|
||||||
Snapshot.defaultLayer,
|
Snapshot.defaultLayer,
|
||||||
Vcs.defaultLayer,
|
Vcs.defaultLayer,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as InstanceState from "@/effect/instance-state"
|
|||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
@@ -12,6 +13,7 @@ import { InstanceHttpApi } from "../api"
|
|||||||
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
const locations = yield* LocationServiceMap
|
const locations = yield* LocationServiceMap
|
||||||
|
|
||||||
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||||
@@ -29,11 +31,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
|
||||||
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
|
||||||
}) {
|
}) {
|
||||||
|
const directory = (yield* InstanceState.context).directory
|
||||||
|
const limit = ctx.query.limit ?? 10
|
||||||
|
const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
|
||||||
|
// Prefer fff (frecency + fuzzy ranking) and trust its ordering. Fall back
|
||||||
|
// to the ripgrep-backed FileSystem.find when fff is unavailable.
|
||||||
|
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
|
||||||
|
if (fff !== undefined) return fff
|
||||||
return (yield* filesystem(
|
return (yield* filesystem(
|
||||||
FileSystem.Service.use((fs) =>
|
FileSystem.Service.use((fs) =>
|
||||||
fs.find({
|
fs.find({
|
||||||
query: ctx.query.query,
|
query: ctx.query.query,
|
||||||
limit: ctx.query.limit ?? 10,
|
limit,
|
||||||
type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
|
type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -91,4 +100,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
|
|||||||
.handle("content", content)
|
.handle("content", content)
|
||||||
.handle("status", status)
|
.handle("status", status)
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(LocationServiceMap.layer))
|
).pipe(Layer.provide(LocationServiceMap.layer), Layer.provide(Search.defaultLayer))
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Option, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -19,9 +18,9 @@ export const Parameters = Schema.Struct({
|
|||||||
export const GlobTool = Tool.define(
|
export const GlobTool = Tool.define(
|
||||||
"glob",
|
"glob",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const rg = yield* Ripgrep.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
const searchSvc = yield* Search.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -52,36 +51,18 @@ export const GlobTool = Tool.define(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
let truncated = false
|
const files = yield* searchSvc.glob({
|
||||||
const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
|
cwd: search,
|
||||||
Stream.mapEffect((file) =>
|
pattern: params.pattern,
|
||||||
Effect.gen(function* () {
|
limit,
|
||||||
const full = path.resolve(search, file)
|
signal: ctx.abort,
|
||||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
})
|
||||||
const mtime =
|
|
||||||
info?.mtime.pipe(
|
|
||||||
Option.map((date) => date.getTime()),
|
|
||||||
Option.getOrElse(() => 0),
|
|
||||||
) ?? 0
|
|
||||||
return { path: full, mtime }
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Stream.take(limit + 1),
|
|
||||||
Stream.runCollect,
|
|
||||||
Effect.map((chunk) => [...chunk]),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (files.length > limit) {
|
|
||||||
truncated = true
|
|
||||||
files.length = limit
|
|
||||||
}
|
|
||||||
files.sort((a, b) => b.mtime - a.mtime)
|
|
||||||
|
|
||||||
const output = []
|
const output = []
|
||||||
if (files.length === 0) output.push("No files found")
|
if (files.files.length === 0) output.push("No files found")
|
||||||
if (files.length > 0) {
|
if (files.files.length > 0) {
|
||||||
output.push(...files.map((file) => file.path))
|
output.push(...files.files)
|
||||||
if (truncated) {
|
if (files.truncated) {
|
||||||
output.push("")
|
output.push("")
|
||||||
output.push(
|
output.push(
|
||||||
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
|
||||||
@@ -92,8 +73,8 @@ export const GlobTool = Tool.define(
|
|||||||
return {
|
return {
|
||||||
title: path.relative(ins.worktree, search),
|
title: path.relative(ins.worktree, search),
|
||||||
metadata: {
|
metadata: {
|
||||||
count: files.length,
|
count: files.files.length,
|
||||||
truncated,
|
truncated: files.truncated,
|
||||||
},
|
},
|
||||||
output: output.join("\n"),
|
output: output.join("\n"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
- Fast file pattern matching tool that works with any codebase size
|
- Fast file pattern matching tool that works with any codebase size
|
||||||
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
||||||
- Returns matching file paths sorted by modification time
|
- Returns matching file paths
|
||||||
- Use this tool when you need to find files by name patterns
|
- Use this tool when you need to find files by name patterns
|
||||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||||
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Effect, Option } from "effect"
|
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -25,7 +24,7 @@ export const GrepTool = Tool.define(
|
|||||||
"grep",
|
"grep",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const searchSvc = yield* Search.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -69,7 +68,7 @@ export const GrepTool = Tool.define(
|
|||||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||||
|
|
||||||
const result = yield* rg.search({
|
const result = yield* searchSvc.search({
|
||||||
cwd,
|
cwd,
|
||||||
pattern: params.pattern,
|
pattern: params.pattern,
|
||||||
glob: params.include ? [params.include] : undefined,
|
glob: params.include ? [params.include] : undefined,
|
||||||
@@ -83,38 +82,15 @@ export const GrepTool = Tool.define(
|
|||||||
line: item.line_number,
|
line: item.line_number,
|
||||||
text: item.lines.text,
|
text: item.lines.text,
|
||||||
}))
|
}))
|
||||||
const times = new Map(
|
|
||||||
(yield* Effect.forEach(
|
|
||||||
[...new Set(rows.map((row) => row.path))],
|
|
||||||
Effect.fnUntraced(function* (file) {
|
|
||||||
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!info || info.type === "Directory") return undefined
|
|
||||||
return [
|
|
||||||
file,
|
|
||||||
info.mtime.pipe(
|
|
||||||
Option.map((time) => time.getTime()),
|
|
||||||
Option.getOrElse(() => 0),
|
|
||||||
) ?? 0,
|
|
||||||
] as const
|
|
||||||
}),
|
|
||||||
{ concurrency: 16 },
|
|
||||||
)).filter((entry): entry is readonly [string, number] => Boolean(entry)),
|
|
||||||
)
|
|
||||||
const matches = rows.flatMap((row) => {
|
|
||||||
const mtime = times.get(row.path)
|
|
||||||
if (mtime === undefined) return []
|
|
||||||
return [{ ...row, mtime }]
|
|
||||||
})
|
|
||||||
|
|
||||||
matches.sort((a, b) => b.mtime - a.mtime)
|
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
const truncated = matches.length > limit
|
const truncated = rows.length > limit
|
||||||
const final = truncated ? matches.slice(0, limit) : matches
|
const final = truncated ? rows.slice(0, limit) : rows
|
||||||
if (final.length === 0) return empty
|
if (final.length === 0) return empty
|
||||||
|
|
||||||
const total = matches.length
|
const total = rows.length
|
||||||
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
const hasMore = truncated || result.hasNextPage
|
||||||
|
const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
|
||||||
|
|
||||||
let current = ""
|
let current = ""
|
||||||
for (const match of final) {
|
for (const match of final) {
|
||||||
@@ -135,11 +111,23 @@ export const GrepTool = Tool.define(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.hasNextPage) {
|
||||||
|
output.push("")
|
||||||
|
output.push(
|
||||||
|
`(Results truncated. Consider using a more specific path or pattern.)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (result.partial) {
|
if (result.partial) {
|
||||||
output.push("")
|
output.push("")
|
||||||
output.push("(Some paths were inaccessible and skipped)")
|
output.push("(Some paths were inaccessible and skipped)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.regexFallbackError) {
|
||||||
|
output.push("")
|
||||||
|
output.push(`(Regex fallback: ${result.regexFallbackError})`)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: params.pattern,
|
title: params.pattern,
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
- Searches file contents using regular expressions
|
- Searches file contents using regular expressions
|
||||||
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||||
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
||||||
- Returns file paths and line numbers with at least one match sorted by modification time
|
- Returns file paths and line numbers with matching lines
|
||||||
- Use this tool when you need to find files containing specific patterns
|
- Use this tool when you need to find files containing specific patterns
|
||||||
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
||||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { Instruction } from "../session/instruction"
|
import { Instruction } from "../session/instruction"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ type Metadata = {
|
|||||||
export const ReadTool = Tool.define<
|
export const ReadTool = Tool.define<
|
||||||
typeof Parameters,
|
typeof Parameters,
|
||||||
Metadata,
|
Metadata,
|
||||||
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Scope.Scope
|
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Search.Service | Scope.Scope
|
||||||
>(
|
>(
|
||||||
"read",
|
"read",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -73,6 +74,7 @@ export const ReadTool = Tool.define<
|
|||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const lsp = yield* LSP.Service
|
const lsp = yield* LSP.Service
|
||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
|
const search = yield* Search.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
|
||||||
@@ -117,6 +119,7 @@ export const ReadTool = Tool.define<
|
|||||||
})
|
})
|
||||||
|
|
||||||
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
|
||||||
|
yield* search.open({ file: filepath }).pipe(Effect.ignore)
|
||||||
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
|
// LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
|
||||||
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
|
yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
@@ -101,7 +101,7 @@ export const layer: Layer.Layer<
|
|||||||
| EventV2Bridge.Service
|
| EventV2Bridge.Service
|
||||||
| HttpClient.HttpClient
|
| HttpClient.HttpClient
|
||||||
| ChildProcessSpawner
|
| ChildProcessSpawner
|
||||||
| Ripgrep.Service
|
| Search.Service
|
||||||
| Format.Service
|
| Format.Service
|
||||||
| Truncate.Service
|
| Truncate.Service
|
||||||
| RuntimeFlags.Service
|
| RuntimeFlags.Service
|
||||||
@@ -386,7 +386,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Search.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
.pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path"
|
|||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import DESCRIPTION from "./skill.txt"
|
import DESCRIPTION from "./skill.txt"
|
||||||
@@ -15,7 +15,7 @@ export const SkillTool = Tool.define(
|
|||||||
"skill",
|
"skill",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const searchSvc = yield* Search.Service
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
@@ -36,7 +36,7 @@ export const SkillTool = Tool.define(
|
|||||||
const dir = path.dirname(info.location)
|
const dir = path.dirname(info.location)
|
||||||
const base = pathToFileURL(dir).href
|
const base = pathToFileURL(dir).href
|
||||||
const limit = 10
|
const limit = 10
|
||||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
||||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||||
Stream.map((file) => path.resolve(dir, file)),
|
Stream.map((file) => path.resolve(dir, file)),
|
||||||
Stream.take(limit),
|
Stream.take(limit),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { ToolRegistry } from "@/tool/registry"
|
|||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference/reference"
|
||||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||||
@@ -196,7 +196,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
|
|||||||
Layer.provide(RepositoryCache.defaultLayer),
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Search.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import { ToolRegistry } from "@/tool/registry"
|
|||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
import { Reference } from "../../src/reference/reference"
|
import { Reference } from "../../src/reference/reference"
|
||||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||||
@@ -142,7 +142,7 @@ function makeHttp() {
|
|||||||
Layer.provide(RepositoryCache.defaultLayer),
|
Layer.provide(RepositoryCache.defaultLayer),
|
||||||
Layer.provide(Git.defaultLayer),
|
Layer.provide(Git.defaultLayer),
|
||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Search.defaultLayer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
|
|||||||
import { GlobTool } from "../../src/tool/glob"
|
import { GlobTool } from "../../src/tool/glob"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
@@ -17,6 +17,7 @@ import { RepositoryCache } from "@/reference/repository-cache"
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import type * as Tool from "../../src/tool/tool"
|
import type * as Tool from "../../src/tool/tool"
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Search.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
@@ -40,6 +41,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
|
|
||||||
const it = testEffect(toolLayer())
|
const it = testEffect(toolLayer())
|
||||||
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||||
|
const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p)
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
@@ -172,7 +174,7 @@ describe("tool.glob", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(result.metadata.count).toBe(1)
|
expect(result.metadata.count).toBe(1)
|
||||||
expect(result.output).toContain(path.join(cache, "src", "index.ts"))
|
expect(full(result.output)).toContain(full(path.join(cache, "src", "index.ts")))
|
||||||
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
|
expect(items.find((item) => item.permission === "external_directory")).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Truncate } from "@/tool/truncate"
|
import { Truncate } from "@/tool/truncate"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
@@ -34,7 +34,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
FSUtil.defaultLayer,
|
FSUtil.defaultLayer,
|
||||||
Ripgrep.defaultLayer,
|
Search.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
Agent.defaultLayer,
|
Agent.defaultLayer,
|
||||||
Git.defaultLayer,
|
Git.defaultLayer,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { Permission } from "../../src/permission"
|
import { Permission } from "../../src/permission"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
@@ -59,6 +60,7 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
referenceLayer(flags),
|
referenceLayer(flags),
|
||||||
|
Search.defaultLayer,
|
||||||
Truncate.defaultLayer,
|
Truncate.defaultLayer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction"
|
|||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
|
import { Search } from "@opencode-ai/core/filesystem/search"
|
||||||
import * as Truncate from "@/tool/truncate"
|
import * as Truncate from "@/tool/truncate"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
@@ -69,7 +69,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(Format.defaultLayer),
|
Layer.provide(Format.defaultLayer),
|
||||||
Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
|
Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
|
||||||
Layer.provide(Ripgrep.defaultLayer),
|
Layer.provide(Search.defaultLayer),
|
||||||
Layer.provide(Truncate.defaultLayer),
|
Layer.provide(Truncate.defaultLayer),
|
||||||
)
|
)
|
||||||
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
|
.pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
|
||||||
|
|||||||
Reference in New Issue
Block a user