Dax Raad 7 месяцев назад
Родитель
Сommit
de15e67834

+ 9 - 6
packages/opencode/src/app/app.ts

@@ -96,13 +96,16 @@ export namespace App {
     }
 
     return ctx.provide(app, async () => {
-      const result = await cb(app.info)
-      for (const [key, entry] of app.services.entries()) {
-        if (!entry.shutdown) continue
-        log.info("shutdown", { name: key })
-        await entry.shutdown?.(await entry.state)
+      try {
+        const result = await cb(app.info)
+        return result
+      } finally {
+        for (const [key, entry] of app.services.entries()) {
+          if (!entry.shutdown) continue
+          log.info("shutdown", { name: key })
+          await entry.shutdown?.(await entry.state)
+        }
       }
-      return result
     })
   }
 

+ 74 - 16
packages/opencode/src/cli/cmd/debug.ts

@@ -1,18 +1,20 @@
 import { App } from "../../app/app"
 import { Ripgrep } from "../../file/ripgrep"
+import { File } from "../../file"
 import { LSP } from "../../lsp"
 import { Log } from "../../util/log"
 import { bootstrap } from "../bootstrap"
 import { cmd } from "./cmd"
+import path from "path"
 
 export const DebugCommand = cmd({
   command: "debug",
   builder: (yargs) =>
     yargs
       .command(DiagnosticsCommand)
-      .command(TreeCommand)
+      .command(RipgrepCommand)
       .command(SymbolsCommand)
-      .command(FilesCommand)
+      .command(FileReadCommand)
       .demandCommand(),
   async handler() {},
 })
@@ -23,26 +25,13 @@ const DiagnosticsCommand = cmd({
     yargs.positional("file", { type: "string", demandOption: true }),
   async handler(args) {
     await bootstrap({ cwd: process.cwd() }, async () => {
+      await LSP.touchFile(args.file, true)
       await LSP.touchFile(args.file, true)
       console.log(await LSP.diagnostics())
     })
   },
 })
 
-const TreeCommand = cmd({
-  command: "tree",
-  builder: (yargs) =>
-    yargs.option("limit", {
-      type: "number",
-    }),
-  async handler(args) {
-    await bootstrap({ cwd: process.cwd() }, async () => {
-      const app = App.info()
-      console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
-    })
-  },
-})
-
 const SymbolsCommand = cmd({
   command: "symbols <query>",
   builder: (yargs) =>
@@ -57,6 +46,31 @@ const SymbolsCommand = cmd({
   },
 })
 
+const RipgrepCommand = cmd({
+  command: "rg",
+  builder: (yargs) =>
+    yargs
+      .command(TreeCommand)
+      .command(FilesCommand)
+      .command(SearchCommand)
+      .demandCommand(),
+  async handler() {},
+})
+
+const TreeCommand = cmd({
+  command: "tree",
+  builder: (yargs) =>
+    yargs.option("limit", {
+      type: "number",
+    }),
+  async handler(args) {
+    await bootstrap({ cwd: process.cwd() }, async () => {
+      const app = App.info()
+      console.log(await Ripgrep.tree({ cwd: app.path.cwd, limit: args.limit }))
+    })
+  },
+})
+
 const FilesCommand = cmd({
   command: "files",
   builder: (yargs) =>
@@ -86,3 +100,47 @@ const FilesCommand = cmd({
     })
   },
 })
+
+const SearchCommand = cmd({
+  command: "search <pattern>",
+  builder: (yargs) =>
+    yargs
+      .positional("pattern", {
+        type: "string",
+        demandOption: true,
+        description: "Search pattern",
+      })
+      .option("glob", {
+        type: "array",
+        description: "File glob patterns",
+      })
+      .option("limit", {
+        type: "number",
+        description: "Limit number of results",
+      }),
+  async handler(args) {
+    const results = await Ripgrep.search({
+      cwd: process.cwd(),
+      pattern: args.pattern,
+      glob: args.glob as string[] | undefined,
+      limit: args.limit,
+    })
+    console.log(JSON.stringify(results, null, 2))
+  },
+})
+
+const FileReadCommand = cmd({
+  command: "file-read <path>",
+  builder: (yargs) =>
+    yargs.positional("path", {
+      type: "string",
+      demandOption: true,
+      description: "File path to read",
+    }),
+  async handler(args) {
+    await bootstrap({ cwd: process.cwd() }, async () => {
+      const content = await File.read(path.resolve(args.path))
+      console.log(content)
+    })
+  },
+})

+ 25 - 0
packages/opencode/src/file/index.ts

@@ -1,5 +1,8 @@
 import { z } from "zod"
 import { Bus } from "../bus"
+import { $ } from "bun"
+import { createPatch } from "diff"
+import path from "path"
 
 export namespace File {
   export const Event = {
@@ -10,4 +13,26 @@ export namespace File {
       }),
     ),
   }
+
+  export async function read(file: string) {
+    const content = await Bun.file(file).text()
+    const gitDiff = await $`git diff HEAD -- ${file}`
+      .cwd(path.dirname(file))
+      .quiet()
+      .nothrow()
+      .text()
+    if (gitDiff.trim()) {
+      const relativePath = path.relative(process.cwd(), file)
+      const originalContent = await $`git show HEAD:./${relativePath}`
+        .cwd(process.cwd())
+        .quiet()
+        .nothrow()
+        .text()
+      if (originalContent.trim()) {
+        const patch = createPatch(file, originalContent, content)
+        return patch
+      }
+    }
+    return content.trim()
+  }
 }

+ 118 - 0
packages/opencode/src/file/ripgrep.ts

@@ -1,3 +1,4 @@
+// Ripgrep utility functions
 import path from "path"
 import { Global } from "../global"
 import fs from "fs/promises"
@@ -8,6 +9,82 @@ import { $ } from "bun"
 import { Fzf } from "./fzf"
 
 export namespace Ripgrep {
+  const Stats = z.object({
+    elapsed: z.object({
+      secs: z.number(),
+      nanos: z.number(),
+      human: z.string(),
+    }),
+    searches: z.number(),
+    searches_with_match: z.number(),
+    bytes_searched: z.number(),
+    bytes_printed: z.number(),
+    matched_lines: z.number(),
+    matches: z.number(),
+  })
+
+  const Begin = z.object({
+    type: z.literal("begin"),
+    data: z.object({
+      path: z.object({
+        text: z.string(),
+      }),
+    }),
+  })
+
+  const Match = z.object({
+    type: z.literal("match"),
+    data: z.object({
+      path: z.object({
+        text: z.string(),
+      }),
+      lines: z.object({
+        text: z.string(),
+      }),
+      line_number: z.number(),
+      absolute_offset: z.number(),
+      submatches: z.array(
+        z.object({
+          match: z.object({
+            text: z.string(),
+          }),
+          start: z.number(),
+          end: z.number(),
+        }),
+      ),
+    }),
+  })
+
+  const End = z.object({
+    type: z.literal("end"),
+    data: z.object({
+      path: z.object({
+        text: z.string(),
+      }),
+      binary_offset: z.number().nullable(),
+      stats: Stats,
+    }),
+  })
+
+  const Summary = z.object({
+    type: z.literal("summary"),
+    data: z.object({
+      elapsed_total: z.object({
+        human: z.string(),
+        nanos: z.number(),
+        secs: z.number(),
+      }),
+      stats: Stats,
+    }),
+  })
+
+  const Result = z.union([Begin, Match, End, Summary])
+
+  export type Result = z.infer<typeof Result>
+  export type Match = z.infer<typeof Match>
+  export type Begin = z.infer<typeof Begin>
+  export type End = z.infer<typeof End>
+  export type Summary = z.infer<typeof Summary>
   const PLATFORM = {
     darwin: { platform: "apple-darwin", extension: "tar.gz" },
     linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
@@ -229,4 +306,45 @@ export namespace Ripgrep {
 
     return lines.join("\n")
   }
+
+  export async function search(input: {
+    cwd: string
+    pattern: string
+    glob?: string[]
+    limit?: number
+  }) {
+    const args = [
+      `${await filepath()}`,
+      "--json",
+      "--hidden",
+      "--glob='!.git/*'",
+    ]
+
+    if (input.glob) {
+      for (const g of input.glob) {
+        args.push(`--glob=${g}`)
+      }
+    }
+
+    if (input.limit) {
+      args.push(`--max-count=${input.limit}`)
+    }
+
+    args.push(input.pattern)
+
+    const command = args.join(" ")
+    const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
+    if (result.exitCode !== 0) {
+      return []
+    }
+
+    const lines = result.text().trim().split("\n").filter(Boolean)
+    // Parse JSON lines from ripgrep output
+
+    return lines
+      .map((line) => JSON.parse(line))
+      .map((parsed) => Result.parse(parsed))
+      .filter((r) => r.type === "match")
+      .map((r) => r.data)
+  }
 }

+ 27 - 39
packages/opencode/src/lsp/client.ts

@@ -12,6 +12,7 @@ import { Bus } from "../bus"
 import z from "zod"
 import type { LSPServer } from "./server"
 import { NamedError } from "../util/error"
+import { withTimeout } from "../util/timeout"
 
 export namespace LSPClient {
   const log = Log.create({ service: "lsp.client" })
@@ -52,7 +53,9 @@ export namespace LSPClient {
       log.info("textDocument/publishDiagnostics", {
         path,
       })
+      const exists = diagnostics.has(path)
       diagnostics.set(path, params.diagnostics)
+      if (!exists && serverID === "typescript") return
       Bus.publish(Event.Diagnostics, { path, serverID })
     })
     connection.onRequest("workspace/configuration", async () => {
@@ -61,7 +64,7 @@ export namespace LSPClient {
     connection.listen()
 
     log.info("sending initialize", { id: serverID })
-    await Promise.race([
+    await withTimeout(
       connection.sendRequest("initialize", {
         processId: server.process.pid,
         workspaceFolders: [
@@ -88,12 +91,10 @@ export namespace LSPClient {
           },
         },
       }),
-      new Promise((_, reject) => {
-        setTimeout(() => {
-          reject(new InitializeError({ serverID }))
-        }, 5_000)
-      }),
-    ])
+      5_000,
+    ).catch(() => {
+      throw new InitializeError({ serverID })
+    })
     await connection.sendNotification("initialized", {})
     log.info("initialized")
 
@@ -116,36 +117,28 @@ export namespace LSPClient {
           const file = Bun.file(input.path)
           const text = await file.text()
           const version = files[input.path]
-          if (version === undefined) {
-            log.info("textDocument/didOpen", input)
+          if (version !== undefined) {
             diagnostics.delete(input.path)
-            const extension = path.extname(input.path)
-            const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
-            await connection.sendNotification("textDocument/didOpen", {
+            await connection.sendNotification("textDocument/didClose", {
               textDocument: {
                 uri: `file://` + input.path,
-                languageId,
-                version: 0,
-                text,
               },
             })
-            files[input.path] = 0
-            return
           }
-
-          log.info("textDocument/didChange", input)
+          log.info("textDocument/didOpen", input)
           diagnostics.delete(input.path)
-          await connection.sendNotification("textDocument/didChange", {
+          const extension = path.extname(input.path)
+          const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
+          await connection.sendNotification("textDocument/didOpen", {
             textDocument: {
               uri: `file://` + input.path,
-              version: ++files[input.path],
+              languageId,
+              version: 0,
+              text,
             },
-            contentChanges: [
-              {
-                text,
-              },
-            ],
           })
+          files[input.path] = 0
+          return
         },
       },
       get diagnostics() {
@@ -157,35 +150,30 @@ export namespace LSPClient {
           : path.resolve(app.path.cwd, input.path)
         log.info("waiting for diagnostics", input)
         let unsub: () => void
-        let timeout: NodeJS.Timeout
-        return await Promise.race([
-          new Promise<void>(async (resolve) => {
+        return await withTimeout(
+          new Promise<void>((resolve) => {
             unsub = Bus.subscribe(Event.Diagnostics, (event) => {
               if (
                 event.properties.path === input.path &&
                 event.properties.serverID === result.serverID
               ) {
                 log.info("got diagnostics", input)
-                clearTimeout(timeout)
                 unsub?.()
                 resolve()
               }
             })
           }),
-          new Promise<void>((resolve) => {
-            timeout = setTimeout(() => {
-              log.info("timed out refreshing diagnostics", input)
-              unsub?.()
-              resolve()
-            }, 5000)
-          }),
-        ])
+          5000,
+        ).finally(() => {
+          unsub?.()
+        })
       },
       async shutdown() {
-        log.info("shutting down")
+        log.info("shutting down", { serverID })
         connection.end()
         connection.dispose()
         server.process.kill("SIGTERM")
+        log.info("shutdown", { serverID })
       },
     }
 

+ 31 - 42
packages/opencode/src/lsp/index.ts

@@ -10,13 +10,29 @@ export namespace LSP {
 
   const state = App.state(
     "lsp",
-    async () => {
+    async (app) => {
       log.info("initializing")
       const clients = new Map<string, LSPClient.Info>()
-      const skip = new Set<string>()
+      for (const server of Object.values(LSPServer)) {
+        for (const extension of server.extensions) {
+          const [file] = await Ripgrep.files({
+            cwd: app.path.cwd,
+            glob: "*" + extension,
+          })
+          if (!file) continue
+          const handle = await server.spawn(App.info())
+          if (!handle) break
+          const client = await LSPClient.create(server.id, handle).catch(
+            () => {},
+          )
+          if (!client) break
+          clients.set(server.id, client)
+          break
+        }
+      }
+      log.info("initialized")
       return {
         clients,
-        skip,
       }
     },
     async (state) => {
@@ -27,49 +43,22 @@ export namespace LSP {
   )
 
   export async function init() {
-    log.info("init")
-    const app = App.info()
-    const result = Object.values(LSPServer).map(async (x) => {
-      for (const extension of x.extensions) {
-        const [file] = await Ripgrep.files({
-          cwd: app.path.cwd,
-          glob: "*" + extension,
-        })
-        if (!file) continue
-        await LSP.touchFile(file, true)
-        break
-      }
-    })
-    return Promise.all(result)
+    return state()
   }
 
   export async function touchFile(input: string, waitForDiagnostics?: boolean) {
     const extension = path.parse(input).ext
-    const s = await state()
-    const matches = Object.values(LSPServer).filter((x) =>
-      x.extensions.includes(extension),
-    )
-    for (const match of matches) {
-      const existing = s.clients.get(match.id)
-      if (existing) continue
-      if (s.skip.has(match.id)) continue
-      s.skip.add(match.id)
-      const handle = await match.spawn(App.info())
-      if (!handle) continue
-      const client = await LSPClient.create(match.id, handle).catch(() => {})
-      if (!client) {
-        s.skip.add(match.id)
-        continue
-      }
-      s.clients.set(match.id, client)
-    }
-    if (waitForDiagnostics) {
-      await run(async (client) => {
-        const wait = client.waitForDiagnostics({ path: input })
-        await client.notify.open({ path: input })
-        return wait
-      })
-    }
+    const matches = Object.values(LSPServer)
+      .filter((x) => x.extensions.includes(extension))
+      .map((x) => x.id)
+    await run(async (client) => {
+      if (!matches.includes(client.serverID)) return
+      const wait = waitForDiagnostics
+        ? client.waitForDiagnostics({ path: input })
+        : Promise.resolve()
+      await client.notify.open({ path: input })
+      return wait
+    })
   }
 
   export async function diagnostics() {

+ 1 - 1
packages/opencode/src/tool/read.ts

@@ -89,7 +89,7 @@ export const ReadTool = Tool.define({
     output += "\n</file>"
 
     // just warms the lsp client
-    await LSP.touchFile(filePath, true)
+    await LSP.touchFile(filePath, false)
     FileTime.read(ctx.sessionID, filePath)
 
     return {

+ 14 - 0
packages/opencode/src/util/timeout.ts

@@ -0,0 +1,14 @@
+export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
+  let timeout: NodeJS.Timeout
+  return Promise.race([
+    promise.then((result) => {
+      clearTimeout(timeout)
+      return result
+    }),
+    new Promise<never>((_, reject) => {
+      timeout = setTimeout(() => {
+        reject(new Error(`Operation timed out after ${ms}ms`))
+      }, ms)
+    }),
+  ])
+}