v0.2.2 872a312

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/packages/core"
12	"github.com/patriceckhart/zot/packages/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 = `{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
32
33func (t *EditTool) Name() string { return "edit" }
34func (t *EditTool) Description() string {
35	return "Edit a file via exact-match replacements. Each oldText must be unique in the file."
36}
37func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
38
39func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
40	var a editArgs
41	if err := json.Unmarshal(raw, &a); err != nil {
42		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
43	}
44	if a.Path == "" {
45		return core.ToolResult{}, fmt.Errorf("path is required")
46	}
47	if len(a.Edits) == 0 {
48		return core.ToolResult{}, fmt.Errorf("at least one edit is required")
49	}
50	path := resolvePath(t.CWD, a.Path)
51	if err := t.Sandbox.CheckPath(path); err != nil {
52		return core.ToolResult{}, err
53	}
54
55	orig, err := os.ReadFile(path)
56	if err != nil {
57		return core.ToolResult{}, err
58	}
59
60	// Detect BOM and line endings.
61	var bom []byte
62	if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
63		bom = orig[:3]
64		orig = orig[3:]
65	}
66	nl := detectLineEnding(orig)
67	// Normalize to \n for matching.
68	body := string(bytes.ReplaceAll(orig, []byte("\r\n"), []byte("\n")))
69
70	// Validate all edits first (against original content, not sequentially).
71	for i, e := range a.Edits {
72		if e.OldText == "" {
73			return core.ToolResult{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
74		}
75		if e.OldText == e.NewText {
76			return core.ToolResult{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
77		}
78		count := strings.Count(body, e.OldText)
79		if count == 0 {
80			return core.ToolResult{}, fmt.Errorf("edit %d: oldText not found in %s", i+1, a.Path)
81		}
82		if count > 1 {
83			return core.ToolResult{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s", i+1, count, a.Path)
84		}
85	}
86
87	// Apply atomically (sorted by position so offsets stay valid as we splice).
88	type span struct {
89		start, end  int
90		replacement string
91	}
92	spans := make([]span, 0, len(a.Edits))
93	for _, e := range a.Edits {
94		idx := strings.Index(body, e.OldText)
95		spans = append(spans, span{start: idx, end: idx + len(e.OldText), replacement: e.NewText})
96	}
97	// Check for overlaps.
98	for i := 0; i < len(spans); i++ {
99		for j := i + 1; j < len(spans); j++ {
100			if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
101				return core.ToolResult{}, fmt.Errorf("edits overlap; merge them into one edit")
102			}
103		}
104	}
105	// Sort ascending.
106	for i := 1; i < len(spans); i++ {
107		for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
108			spans[j-1], spans[j] = spans[j], spans[j-1]
109		}
110	}
111	var out strings.Builder
112	prev := 0
113	for _, s := range spans {
114		out.WriteString(body[prev:s.start])
115		out.WriteString(s.replacement)
116		prev = s.end
117	}
118	out.WriteString(body[prev:])
119	newBody := out.String()
120
121	// Restore line endings.
122	if nl == "\r\n" {
123		newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
124	}
125	final := append([]byte{}, bom...)
126	final = append(final, []byte(newBody)...)
127
128	if err := os.WriteFile(path, final, 0o644); err != nil {
129		return core.ToolResult{}, err
130	}
131
132	diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
133	// The tool-call header renders the path above the result, so the
134	// result body is just the context diff — no "applied N edit(s)"
135	// prose prefix. The Details map carries the edit count for
136	// programmatic consumers (json mode, rpc clients) that might
137	// want it.
138	return core.ToolResult{
139		Content: []provider.Content{provider.TextBlock{Text: diff}},
140		Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
141	}, nil
142}
143
144func detectLineEnding(b []byte) string {
145	if bytes.Contains(b, []byte("\r\n")) {
146		return "\r\n"
147	}
148	return "\n"
149}
150
151// diffContextLines is the number of unchanged lines kept on each
152// side of an edit when rendering the diff. 3 is the git-diff
153// default and balances readability with transcript size.
154const diffContextLines = 3
155
156// unifiedDiff emits a context diff for the edit tool's result.
157//
158// Shape: each output row is either
159//   - " <line>"       unchanged context
160//   - "-<line>"       deletion (from a)
161//   - "+<line>"       addition (to b)
162//   - "..."           context break between hunks
163//
164// The legacy "--- name / +++ name" header is omitted because the
165// tool-call header above the result already shows the path. Only
166// lines within diffContextLines of a +/- row are kept; longer
167// runs of unchanged content collapse into a single "..." row so
168// a one-line edit in a thousand-line file produces a short
169// transcript.
170func unifiedDiff(name, a, b string) string {
171	if a == b {
172		return ""
173	}
174	aLines := strings.Split(a, "\n")
175	bLines := strings.Split(b, "\n")
176	ops := diffLines(aLines, bLines)
177
178	// Mark ops that sit within diffContextLines of any +/- op.
179	keep := make([]bool, len(ops))
180	for i, op := range ops {
181		if op.kind == '+' || op.kind == '-' {
182			keep[i] = true
183			for d := 1; d <= diffContextLines; d++ {
184				if i-d >= 0 {
185					keep[i-d] = true
186				}
187				if i+d < len(ops) {
188					keep[i+d] = true
189				}
190			}
191		}
192	}
193
194	var sb strings.Builder
195	prevKept := false
196	anyOutput := false
197	for i, op := range ops {
198		if !keep[i] {
199			if prevKept {
200				sb.WriteString("...\n")
201				prevKept = false
202			}
203			continue
204		}
205		if !prevKept && anyOutput {
206			sb.WriteString("...\n")
207		}
208		switch op.kind {
209		case ' ':
210			fmt.Fprintf(&sb, " %s\n", op.line)
211		case '-':
212			fmt.Fprintf(&sb, "-%s\n", op.line)
213		case '+':
214			fmt.Fprintf(&sb, "+%s\n", op.line)
215		}
216		prevKept = true
217		anyOutput = true
218	}
219	_ = name // header dropped; kept in signature for call-site stability
220	return sb.String()
221}
222
223type diffOp struct {
224	kind byte
225	line string
226}
227
228func diffLines(a, b []string) []diffOp {
229	// LCS table.
230	m, n := len(a), len(b)
231	dp := make([][]int, m+1)
232	for i := range dp {
233		dp[i] = make([]int, n+1)
234	}
235	for i := 0; i < m; i++ {
236		for j := 0; j < n; j++ {
237			if a[i] == b[j] {
238				dp[i+1][j+1] = dp[i][j] + 1
239			} else if dp[i+1][j] >= dp[i][j+1] {
240				dp[i+1][j+1] = dp[i+1][j]
241			} else {
242				dp[i+1][j+1] = dp[i][j+1]
243			}
244		}
245	}
246	// Backtrack.
247	var ops []diffOp
248	i, j := m, n
249	for i > 0 && j > 0 {
250		if a[i-1] == b[j-1] {
251			ops = append([]diffOp{{' ', a[i-1]}}, ops...)
252			i--
253			j--
254		} else if dp[i][j-1] >= dp[i-1][j] {
255			ops = append([]diffOp{{'+', b[j-1]}}, ops...)
256			j--
257		} else {
258			ops = append([]diffOp{{'-', a[i-1]}}, ops...)
259			i--
260		}
261	}
262	for i > 0 {
263		ops = append([]diffOp{{'-', a[i-1]}}, ops...)
264		i--
265	}
266	for j > 0 {
267		ops = append([]diffOp{{'+', b[j-1]}}, ops...)
268		j--
269	}
270	return ops
271}

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