search.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. export * as FileSystemSearch from "./search.js"
  2. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  3. import path from "path"
  4. import { Context, Effect, Layer, Schema, Scope } from "effect"
  5. import { Fff } from "#fff"
  6. import fuzzysort from "fuzzysort"
  7. import { FileSystem } from "../filesystem.js"
  8. import { Location } from "../location.js"
  9. import { Ripgrep } from "../ripgrep.js"
  10. import { RelativePath } from "../schema.js"
  11. import { Protected } from "./protected.js"
  12. export interface Interface {
  13. readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
  14. }
  15. export const Options = Schema.Struct({
  16. fff: Schema.optional(Schema.Boolean),
  17. })
  18. export type Options = typeof Options.Type
  19. export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
  20. export const ripgrepLayer = Layer.effect(
  21. Service,
  22. Effect.gen(function* () {
  23. const location = yield* Location.Service
  24. const ripgrep = yield* Ripgrep.Service
  25. const scope = yield* Scope.Scope
  26. const files: string[] = []
  27. const directories = new Set<string>()
  28. const home = Protected.isHome(location.directory)
  29. yield* ripgrep
  30. .find({
  31. cwd: location.directory,
  32. pattern: "*",
  33. limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
  34. exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
  35. onEntry: (entry) =>
  36. Effect.sync(() => {
  37. files.push(entry.path)
  38. const parts = entry.path.split("/")
  39. parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
  40. }),
  41. })
  42. .pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
  43. return Service.of({
  44. find: (input) =>
  45. Effect.gen(function* () {
  46. const items =
  47. input.type === "file"
  48. ? files
  49. : input.type === "directory"
  50. ? Array.from(directories)
  51. : [...files, ...directories]
  52. return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
  53. const relative = item.target
  54. const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
  55. return FileSystem.Entry.make({
  56. path: RelativePath.make(relative),
  57. type,
  58. })
  59. })
  60. }),
  61. })
  62. }),
  63. )
  64. export const fffLayer = Layer.effect(
  65. Service,
  66. Effect.gen(function* () {
  67. const location = yield* Location.Service
  68. const result = yield* Effect.try({
  69. try: () =>
  70. Fff.create({
  71. basePath: location.directory,
  72. aiMode: true,
  73. disableMmapCache: true,
  74. disableContentIndexing: true,
  75. }),
  76. catch: (cause) => cause,
  77. }).pipe(
  78. Effect.catch((error) => Effect.logWarning("failed to initialize fff", { error }).pipe(Effect.as(undefined))),
  79. )
  80. if (!result?.ok) {
  81. if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
  82. return Service.of({
  83. find: () => Effect.succeed([]),
  84. })
  85. }
  86. yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
  87. return Service.of({
  88. find: (input) =>
  89. Effect.sync(() => {
  90. const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
  91. const items = (() => {
  92. if (input.type === "file") {
  93. const found = result.value.fileSearch(input.query.trim(), options)
  94. if (!found.ok) throw found.error
  95. return found.value.items.map((item, index) => ({
  96. path: item.relativePath,
  97. type: "file" as const,
  98. score: found.value.scores[index]?.total ?? 0,
  99. }))
  100. }
  101. if (input.type === "directory") {
  102. const found = result.value.directorySearch(input.query.trim(), options)
  103. if (!found.ok) throw found.error
  104. return found.value.items.map((item, index) => ({
  105. path: item.relativePath,
  106. type: "directory" as const,
  107. score: found.value.scores[index]?.total ?? 0,
  108. }))
  109. }
  110. const found = result.value.mixedSearch(input.query.trim(), options)
  111. if (!found.ok) throw found.error
  112. return found.value.items.map((item, index) => ({
  113. path: item.item.relativePath,
  114. type: item.type,
  115. score: found.value.scores[index]?.total ?? 0,
  116. }))
  117. })()
  118. return items
  119. .sort((a, b) => b.score - a.score || a.path.length - b.path.length)
  120. .map((item) => {
  121. const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
  122. return FileSystem.Entry.make({
  123. path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
  124. type: item.type,
  125. })
  126. })
  127. }),
  128. })
  129. }),
  130. )
  131. export const layer = (options?: Options) =>
  132. Layer.unwrap(
  133. Effect.gen(function* () {
  134. if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
  135. return ripgrepLayer
  136. const location = yield* Location.Service
  137. // Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
  138. return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
  139. }),
  140. )
  141. export function configured(options?: Options) {
  142. return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
  143. }
  144. export const node = configured()