editor.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import type { CliRenderer } from "@opentui/core"
  2. import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
  3. import { readFile, rm, writeFile } from "node:fs/promises"
  4. import os from "node:os"
  5. import path from "node:path"
  6. import { spawn } from "node:child_process"
  7. import type { Stream } from "node:stream"
  8. import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
  9. export { normalizePromptContent } from "./prompt/content"
  10. type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
  11. export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
  12. const editor = process.env.VISUAL || process.env.EDITOR
  13. if (!editor) return
  14. const file = path.join(os.tmpdir(), `${Date.now()}.md`)
  15. await writeFile(file, input.value)
  16. input.renderer.suspend()
  17. input.renderer.currentRenderBuffer.clear()
  18. try {
  19. await new Promise<void>((resolve, reject) => {
  20. const parts = editor.split(" ")
  21. const child = spawn(parts[0]!, [...parts.slice(1), file], {
  22. cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
  23. stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
  24. shell: process.platform === "win32",
  25. })
  26. child.on("error", reject)
  27. child.on("exit", (code, signal) => {
  28. if (code === 0) return resolve()
  29. reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
  30. })
  31. })
  32. return (await readFile(file, "utf8")) || undefined
  33. } finally {
  34. await rm(file, { force: true }).catch(() => {})
  35. input.renderer.currentRenderBuffer.clear()
  36. input.renderer.resume()
  37. input.renderer.requestRender()
  38. }
  39. }
  40. export function discoverEditorConnection(directory: string) {
  41. const root = path.join(os.homedir(), ".claude", "ide")
  42. const contains = (parent: string) => {
  43. const resolved = path.resolve(parent)
  44. const relative = path.relative(resolved, path.resolve(directory))
  45. return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
  46. }
  47. try {
  48. return readdirSync(root)
  49. .filter((entry) => entry.endsWith(".lock"))
  50. .flatMap((entry) => {
  51. const file = path.join(root, entry)
  52. const port = Number.parseInt(path.basename(file, ".lock"), 10)
  53. if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
  54. try {
  55. const value = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>
  56. if (value.transport !== undefined && value.transport !== "ws") return []
  57. const folders = Array.isArray(value.workspaceFolders)
  58. ? value.workspaceFolders.filter((item): item is string => typeof item === "string")
  59. : []
  60. const score = Math.max(0, ...folders.map(contains))
  61. if (!score) return []
  62. return [
  63. {
  64. url: `ws://127.0.0.1:${port}`,
  65. authToken: typeof value.authToken === "string" ? value.authToken : undefined,
  66. source: `lock:${port}`,
  67. score,
  68. mtime: statSync(file).mtimeMs,
  69. },
  70. ]
  71. } catch {
  72. return []
  73. }
  74. })
  75. .sort((left, right) => right.score - left.score || right.mtime - left.mtime)
  76. .map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
  77. } catch {
  78. return undefined
  79. }
  80. }
  81. export const editorIntegration = {
  82. connection: discoverEditorConnection,
  83. selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory),
  84. }