|
|
@@ -5,6 +5,7 @@
|
|
|
|
|
|
import z from "zod"
|
|
|
import * as path from "path"
|
|
|
+import { Effect } from "effect"
|
|
|
import { Tool } from "./tool"
|
|
|
import { LSP } from "../lsp"
|
|
|
import { createTwoFilesPatch, diffLines } from "diff"
|
|
|
@@ -17,7 +18,7 @@ import { FileTime } from "../file/time"
|
|
|
import { Filesystem } from "../util/filesystem"
|
|
|
import { Instance } from "../project/instance"
|
|
|
import { Snapshot } from "@/snapshot"
|
|
|
-import { assertExternalDirectory } from "./external-directory"
|
|
|
+import { assertExternalDirectoryEffect } from "./external-directory"
|
|
|
|
|
|
const MAX_DIAGNOSTICS_PER_FILE = 20
|
|
|
|
|
|
@@ -34,136 +35,161 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
|
|
|
return text.replaceAll("\n", "\r\n")
|
|
|
}
|
|
|
|
|
|
-export const EditTool = Tool.define("edit", {
|
|
|
- description: DESCRIPTION,
|
|
|
- parameters: z.object({
|
|
|
- filePath: z.string().describe("The absolute path to the file to modify"),
|
|
|
- oldString: z.string().describe("The text to replace"),
|
|
|
- newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
|
|
- replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
|
|
- }),
|
|
|
- async execute(params, ctx) {
|
|
|
- if (!params.filePath) {
|
|
|
- throw new Error("filePath is required")
|
|
|
- }
|
|
|
+const Parameters = z.object({
|
|
|
+ filePath: z.string().describe("The absolute path to the file to modify"),
|
|
|
+ oldString: z.string().describe("The text to replace"),
|
|
|
+ newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
|
|
+ replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
|
|
+})
|
|
|
|
|
|
- if (params.oldString === params.newString) {
|
|
|
- throw new Error("No changes to apply: oldString and newString are identical.")
|
|
|
- }
|
|
|
+export const EditTool = Tool.defineEffect(
|
|
|
+ "edit",
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const lsp = yield* LSP.Service
|
|
|
+ const filetime = yield* FileTime.Service
|
|
|
|
|
|
- const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
|
|
- await assertExternalDirectory(ctx, filePath)
|
|
|
-
|
|
|
- let diff = ""
|
|
|
- let contentOld = ""
|
|
|
- let contentNew = ""
|
|
|
- await FileTime.withLock(filePath, async () => {
|
|
|
- if (params.oldString === "") {
|
|
|
- const existed = await Filesystem.exists(filePath)
|
|
|
- contentNew = params.newString
|
|
|
- diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
|
|
- await ctx.ask({
|
|
|
- permission: "edit",
|
|
|
- patterns: [path.relative(Instance.worktree, filePath)],
|
|
|
- always: ["*"],
|
|
|
- metadata: {
|
|
|
- filepath: filePath,
|
|
|
- diff,
|
|
|
- },
|
|
|
- })
|
|
|
- await Filesystem.write(filePath, params.newString)
|
|
|
- await Format.file(filePath)
|
|
|
- Bus.publish(File.Event.Edited, { file: filePath })
|
|
|
- await Bus.publish(FileWatcher.Event.Updated, {
|
|
|
- file: filePath,
|
|
|
- event: existed ? "change" : "add",
|
|
|
- })
|
|
|
- await FileTime.read(ctx.sessionID, filePath)
|
|
|
- return
|
|
|
- }
|
|
|
+ return {
|
|
|
+ description: DESCRIPTION,
|
|
|
+ parameters: Parameters,
|
|
|
+ execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ if (!params.filePath) {
|
|
|
+ throw new Error("filePath is required")
|
|
|
+ }
|
|
|
|
|
|
- const stats = Filesystem.stat(filePath)
|
|
|
- if (!stats) throw new Error(`File ${filePath} not found`)
|
|
|
- if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
|
|
- await FileTime.assert(ctx.sessionID, filePath)
|
|
|
- contentOld = await Filesystem.readText(filePath)
|
|
|
-
|
|
|
- const ending = detectLineEnding(contentOld)
|
|
|
- const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
|
|
- const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
|
|
-
|
|
|
- contentNew = replace(contentOld, old, next, params.replaceAll)
|
|
|
-
|
|
|
- diff = trimDiff(
|
|
|
- createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
|
|
- )
|
|
|
- await ctx.ask({
|
|
|
- permission: "edit",
|
|
|
- patterns: [path.relative(Instance.worktree, filePath)],
|
|
|
- always: ["*"],
|
|
|
- metadata: {
|
|
|
- filepath: filePath,
|
|
|
- diff,
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- await Filesystem.write(filePath, contentNew)
|
|
|
- await Format.file(filePath)
|
|
|
- Bus.publish(File.Event.Edited, { file: filePath })
|
|
|
- await Bus.publish(FileWatcher.Event.Updated, {
|
|
|
- file: filePath,
|
|
|
- event: "change",
|
|
|
- })
|
|
|
- contentNew = await Filesystem.readText(filePath)
|
|
|
- diff = trimDiff(
|
|
|
- createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
|
|
- )
|
|
|
- await FileTime.read(ctx.sessionID, filePath)
|
|
|
- })
|
|
|
+ if (params.oldString === params.newString) {
|
|
|
+ throw new Error("No changes to apply: oldString and newString are identical.")
|
|
|
+ }
|
|
|
|
|
|
- const filediff: Snapshot.FileDiff = {
|
|
|
- file: filePath,
|
|
|
- patch: diff,
|
|
|
- additions: 0,
|
|
|
- deletions: 0,
|
|
|
- }
|
|
|
- for (const change of diffLines(contentOld, contentNew)) {
|
|
|
- if (change.added) filediff.additions += change.count || 0
|
|
|
- if (change.removed) filediff.deletions += change.count || 0
|
|
|
- }
|
|
|
+ const filePath = path.isAbsolute(params.filePath)
|
|
|
+ ? params.filePath
|
|
|
+ : path.join(Instance.directory, params.filePath)
|
|
|
+ yield* assertExternalDirectoryEffect(ctx, filePath)
|
|
|
+
|
|
|
+ let diff = ""
|
|
|
+ let contentOld = ""
|
|
|
+ let contentNew = ""
|
|
|
+ yield* filetime.withLock(filePath, async () => {
|
|
|
+ if (params.oldString === "") {
|
|
|
+ const existed = await Filesystem.exists(filePath)
|
|
|
+ contentNew = params.newString
|
|
|
+ diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
|
|
+ await ctx.ask({
|
|
|
+ permission: "edit",
|
|
|
+ patterns: [path.relative(Instance.worktree, filePath)],
|
|
|
+ always: ["*"],
|
|
|
+ metadata: {
|
|
|
+ filepath: filePath,
|
|
|
+ diff,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ await Filesystem.write(filePath, params.newString)
|
|
|
+ await Format.file(filePath)
|
|
|
+ Bus.publish(File.Event.Edited, { file: filePath })
|
|
|
+ await Bus.publish(FileWatcher.Event.Updated, {
|
|
|
+ file: filePath,
|
|
|
+ event: existed ? "change" : "add",
|
|
|
+ })
|
|
|
+ await FileTime.read(ctx.sessionID, filePath)
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- ctx.metadata({
|
|
|
- metadata: {
|
|
|
- diff,
|
|
|
- filediff,
|
|
|
- diagnostics: {},
|
|
|
- },
|
|
|
- })
|
|
|
+ const stats = Filesystem.stat(filePath)
|
|
|
+ if (!stats) throw new Error(`File ${filePath} not found`)
|
|
|
+ if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
|
|
+ await FileTime.assert(ctx.sessionID, filePath)
|
|
|
+ contentOld = await Filesystem.readText(filePath)
|
|
|
+
|
|
|
+ const ending = detectLineEnding(contentOld)
|
|
|
+ const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
|
|
+ const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
|
|
+
|
|
|
+ contentNew = replace(contentOld, old, next, params.replaceAll)
|
|
|
+
|
|
|
+ diff = trimDiff(
|
|
|
+ createTwoFilesPatch(
|
|
|
+ filePath,
|
|
|
+ filePath,
|
|
|
+ normalizeLineEndings(contentOld),
|
|
|
+ normalizeLineEndings(contentNew),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ await ctx.ask({
|
|
|
+ permission: "edit",
|
|
|
+ patterns: [path.relative(Instance.worktree, filePath)],
|
|
|
+ always: ["*"],
|
|
|
+ metadata: {
|
|
|
+ filepath: filePath,
|
|
|
+ diff,
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ await Filesystem.write(filePath, contentNew)
|
|
|
+ await Format.file(filePath)
|
|
|
+ Bus.publish(File.Event.Edited, { file: filePath })
|
|
|
+ await Bus.publish(FileWatcher.Event.Updated, {
|
|
|
+ file: filePath,
|
|
|
+ event: "change",
|
|
|
+ })
|
|
|
+ contentNew = await Filesystem.readText(filePath)
|
|
|
+ diff = trimDiff(
|
|
|
+ createTwoFilesPatch(
|
|
|
+ filePath,
|
|
|
+ filePath,
|
|
|
+ normalizeLineEndings(contentOld),
|
|
|
+ normalizeLineEndings(contentNew),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ await FileTime.read(ctx.sessionID, filePath)
|
|
|
+ })
|
|
|
+
|
|
|
+ const filediff: Snapshot.FileDiff = {
|
|
|
+ file: filePath,
|
|
|
+ patch: diff,
|
|
|
+ additions: 0,
|
|
|
+ deletions: 0,
|
|
|
+ }
|
|
|
+ for (const change of diffLines(contentOld, contentNew)) {
|
|
|
+ if (change.added) filediff.additions += change.count || 0
|
|
|
+ if (change.removed) filediff.deletions += change.count || 0
|
|
|
+ }
|
|
|
|
|
|
- let output = "Edit applied successfully."
|
|
|
- await LSP.touchFile(filePath, true)
|
|
|
- const diagnostics = await LSP.diagnostics()
|
|
|
- const normalizedFilePath = Filesystem.normalizePath(filePath)
|
|
|
- const issues = diagnostics[normalizedFilePath] ?? []
|
|
|
- const errors = issues.filter((item) => item.severity === 1)
|
|
|
- if (errors.length > 0) {
|
|
|
- const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
|
|
- const suffix =
|
|
|
- errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
|
|
- output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
|
|
- }
|
|
|
+ ctx.metadata({
|
|
|
+ metadata: {
|
|
|
+ diff,
|
|
|
+ filediff,
|
|
|
+ diagnostics: {},
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ let output = "Edit applied successfully."
|
|
|
+ yield* lsp.touchFile(filePath, true)
|
|
|
+ const diagnostics = yield* lsp.diagnostics()
|
|
|
+ const normalizedFilePath = Filesystem.normalizePath(filePath)
|
|
|
+ const issues = diagnostics[normalizedFilePath] ?? []
|
|
|
+ const errors = issues.filter((item) => item.severity === 1)
|
|
|
+ if (errors.length > 0) {
|
|
|
+ const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
|
|
+ const suffix =
|
|
|
+ errors.length > MAX_DIAGNOSTICS_PER_FILE
|
|
|
+ ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
|
|
|
+ : ""
|
|
|
+ output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
|
|
+ }
|
|
|
|
|
|
- return {
|
|
|
- metadata: {
|
|
|
- diagnostics,
|
|
|
- diff,
|
|
|
- filediff,
|
|
|
- },
|
|
|
- title: `${path.relative(Instance.worktree, filePath)}`,
|
|
|
- output,
|
|
|
+ return {
|
|
|
+ metadata: {
|
|
|
+ diagnostics,
|
|
|
+ diff,
|
|
|
+ filediff,
|
|
|
+ },
|
|
|
+ title: `${path.relative(Instance.worktree, filePath)}`,
|
|
|
+ output,
|
|
|
+ }
|
|
|
+ }).pipe(Effect.orDie, Effect.runPromise),
|
|
|
}
|
|
|
- },
|
|
|
-})
|
|
|
+ }),
|
|
|
+)
|
|
|
|
|
|
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
|
|
|