v0.0.33 b9e7517

edit.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"os"
9	"strings"
10
11	"github.com/patriceckhart/zot/internal/core"
12	"github.com/patriceckhart/zot/internal/provider"
13)
14
15// EditTool applies one or more exact-match replacements to a file.
16type EditTool struct {
17	CWD     string
18	Sandbox *Sandbox
19}
20
21type editOp struct {
22	OldText string `json:"oldText"`
23	NewText string `json:"newText"`
24}
25
26type editArgs struct {
27	Path  string   `json:"path"`
28	Edits []editOp `json:"edits"`
29}
30
31const editSchema = `{
32  "type":"object",
33  "properties":{
34    "path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},
35    "edits":{
36      "type":"array",
37      "description":"One or more targeted replacements. Each oldText must match exactly once in the original file.",
38      "items":{
39        "type":"object",
40        "properties":{
41          "oldText":{"type":"string","description":"Exact text to replace. Must be unique in the file."},
42          "newText":{"type":"string","description":"Replacement text."}
43        },
44        "required":["oldText","newText"],
45        "additionalProperties":false
46      }
47    }
48  },
49  "required":["path","edits"],
50  "additionalProperties":false
51}`
52
53func (t *EditTool) Name() string { return "edit" }
54func (t *EditTool) Description() string {
55	return "Edit an existing file via exact-match replacements. Preserves line endings and BOM."
56}
57func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
58
59func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
60	var a editArgs
61	if err := json.Unmarshal(raw, &a); err != nil {
62		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
63	}
64	if a.Path == "" {
65		return core.ToolResult{}, fmt.Errorf("path is required")
66	}
67	if len(a.Edits) == 0 {
68		return core.ToolResult{}, fmt.Errorf("at least one edit is required")
69	}
70	path := resolvePath(t.CWD, a.Path)
71	if err := t.Sandbox.CheckPath(path); err != nil {
72		return core.ToolResult{}, err
73	}
74
75	orig, err := os.ReadFile(path)
76	if err != nil {
77		return core.ToolResult{}, err
78	}
79
80	// Detect BOM and line endings.
81	var bom []byte
82	if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
83		bom = orig[:3]
84		orig = orig[3:]
85	}
86	nl := detectLineEnding(orig)
87	// Normalize to \n for matching.
88	body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
89
90	// Validate all edits first (against original content, not sequentially).
91	for i, e := range a.Edits {
92		if e.OldText == "" {
93			return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
94		}
95		if e.OldText == e.NewText {
96			return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
97		}
98		count := strings.Count(body, e.OldText)
99		if count == 0 {
100			return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)
101		}
102		if count > 1 {
103			return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
104		}
105	}
106
107	// Apply atomically (sorted by position so offsets stay valid as we splice).
108	type span struct {
109		start, end  int
110		replacement string
111	}
112	spans := make([]span, 0, len(a.Edits))
113	for _, e := range a.Edits {
114		idx := strings.Index(body, e.OldText)
115		spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
116	}
117	// Check for overlaps.
118	for i := 0; i < len(spans); i++ {
119		for j := i + 1; j < len(spans); j++ {
120			if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
121				return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")
122			}
123		}
124	}
125	// Sort ascending.
126	for i := 1; i < len(spans); i++ {
127		for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
128			spans[j-1], spans[j] = spans[j], spans[j-1]
129		}
130	}
131	var out strings.Builder
132	prev := 0
133	for _, s := range spans {
134		out.WriteString(body[prev:s.start])
135		out.WriteString(s.replacement)
136		prev = s.end
137	}
138	out.WriteString(body[prev:])
139	newBody := out.String()
140
141	// Restore line endings.
142	if nl == "\r\n" {
143		newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
144	}
145	final := append([]byte{}, bom...)
146	final = append(final, []byte(newBody)...)
147
148	if err := os.WriteFile(path, final, 0o644); err != nil {
149		return core.ToolResult{}, err
150	}
151
152	diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
153	msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path)
154	return core.ToolResult{
155		Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}},
156		Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
157	}, nil
158}
159
160func detectLineEnding(b []byte) string {
161	if bytes.Contains(b, []byte("\r\n")) {
162		return "\r\n"
163	}
164	return "\n"
165}
166
167// unifiedDiff is a minimal unified diff good enough for tool output.
168func unifiedDiff(name, a, b string) string {
169	if a == b {
170		return ""
171	}
172	aLines := strings.Split(a, "\n")
173	bLines := strings.Split(b, "\n")
174	// Use simple LCS-based diff.
175	ops := diffLines(aLines, bLines)
176	var sb strings.Builder
177	fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name)
178	for _, op := range ops {
179		switch op.kind {
180		case ' ':
181			fmt.Fprintf(&sb, " %s\n", op.line)
182		case '-':
183			fmt.Fprintf(&sb, "-%s\n", op.line)
184		case '+':
185			fmt.Fprintf(&sb, "+%s\n", op.line)
186		}
187	}
188	return sb.String()
189}
190
191type diffOp struct {
192	kind byte
193	line string
194}
195
196func diffLines(a, b []string) []diffOp {
197	// LCS table.
198	m, n := len(a), len(b)
199	dp := make([][]int, m+1)
200	for i := range dp {
201		dp[i] = make([]int, n+1)
202	}
203	for i := 0; i < m; i++ {
204		for j := 0; j < n; j++ {
205			if a[i] == b[j] {
206				dp[i+1][j+1] = dp[i][j] + 1
207			} else if dp[i+1][j] >= dp[i][j+1] {
208				dp[i+1][j+1] = dp[i+1][j]
209			} else {
210				dp[i+1][j+1] = dp[i][j+1]
211			}
212		}
213	}
214	// Backtrack.
215	var ops []diffOp
216	i, j := m, n
217	for i > 0 && j > 0 {
218		if a[i-1] == b[j-1] {
219			ops = append([]diffOp{{' ', a[i-1]}}, ops...)
220			i--
221			j--
222		} else if dp[i][j-1] >= dp[i-1][j] {
223			ops = append([]diffOp{{'+', b[j-1]}}, ops...)
224			j--
225		} else {
226			ops = append([]diffOp{{'-', a[i-1]}}, ops...)
227			i--
228		}
229	}
230	for i > 0 {
231		ops = append([]diffOp{{'-', a[i-1]}}, ops...)
232		i--
233	}
234	for j > 0 {
235		ops = append([]diffOp{{'+', b[j-1]}}, ops...)
236		j--
237	}
238	return ops
239}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close