Skip to content

Commit eeb7648

Browse files
committed
Add diffsandbox: virtual file overlay for proposed edits
From plandex-ai/plandex pattern: - Sandbox accumulates proposed changes in memory (create/modify/delete) - ProposeCreate/ProposeModify/ProposeDelete stage changes - Diff() generates unified diff of all accumulated changes - Apply() writes to filesystem atomically (temp+rename) - Discard()/DiscardFile() removes proposals without applying - ApplyFile() for partial application - Summary() and Stats() for change overview - Built-in LCS-based unified diff generator (zero external deps) - Thread-safe (RWMutex), 8-char hex change IDs - 21 tests passing with -race detector
1 parent f4f224c commit eeb7648

3 files changed

Lines changed: 1145 additions & 0 deletions

File tree

‎diffsandbox/diff.go‎

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package diffsandbox
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// unifiedDiff generates a unified diff between old and new content.
9+
// It produces standard unified diff format with @@ hunk headers.
10+
func unifiedDiff(oldPath, newPath, oldContent, newContent string) string {
11+
oldLines := splitLines(oldContent)
12+
newLines := splitLines(newContent)
13+
14+
var b strings.Builder
15+
b.WriteString(fmt.Sprintf("--- %s\n", oldPath))
16+
b.WriteString(fmt.Sprintf("+++ %s\n", newPath))
17+
18+
// Compute LCS-based edit script
19+
lcs := computeLCS(oldLines, newLines)
20+
21+
var edits []edit
22+
oi, ni, li := 0, 0, 0
23+
for li < len(lcs) {
24+
for oi < len(oldLines) && oldLines[oi] != lcs[li] {
25+
edits = append(edits, edit{op: '-', line: oldLines[oi]})
26+
oi++
27+
}
28+
for ni < len(newLines) && newLines[ni] != lcs[li] {
29+
edits = append(edits, edit{op: '+', line: newLines[ni]})
30+
ni++
31+
}
32+
edits = append(edits, edit{op: ' ', line: lcs[li]})
33+
oi++
34+
ni++
35+
li++
36+
}
37+
for oi < len(oldLines) {
38+
edits = append(edits, edit{op: '-', line: oldLines[oi]})
39+
oi++
40+
}
41+
for ni < len(newLines) {
42+
edits = append(edits, edit{op: '+', line: newLines[ni]})
43+
ni++
44+
}
45+
46+
// Group edits into hunks with 3 lines of context
47+
const contextLines = 3
48+
hunks := groupHunks(edits, contextLines)
49+
for _, h := range hunks {
50+
oldStart, oldCount, newStart, newCount := hunkHeader(h, edits)
51+
b.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount))
52+
for _, e := range h {
53+
b.WriteString(fmt.Sprintf("%c%s\n", e.op, e.line))
54+
}
55+
}
56+
57+
return b.String()
58+
}
59+
60+
type edit struct {
61+
op byte
62+
line string
63+
}
64+
65+
// splitLines splits content into lines. An empty string returns nil.
66+
func splitLines(s string) []string {
67+
if s == "" {
68+
return nil
69+
}
70+
lines := strings.Split(s, "\n")
71+
// Remove trailing empty string from a final newline
72+
if len(lines) > 0 && lines[len(lines)-1] == "" {
73+
lines = lines[:len(lines)-1]
74+
}
75+
return lines
76+
}
77+
78+
// computeLCS returns the longest common subsequence of two string slices.
79+
func computeLCS(a, b []string) []string {
80+
m, n := len(a), len(b)
81+
dp := make([][]int, m+1)
82+
for i := range dp {
83+
dp[i] = make([]int, n+1)
84+
}
85+
for i := 1; i <= m; i++ {
86+
for j := 1; j <= n; j++ {
87+
if a[i-1] == b[j-1] {
88+
dp[i][j] = dp[i-1][j-1] + 1
89+
} else if dp[i-1][j] >= dp[i][j-1] {
90+
dp[i][j] = dp[i-1][j]
91+
} else {
92+
dp[i][j] = dp[i][j-1]
93+
}
94+
}
95+
}
96+
97+
lcs := make([]string, 0, dp[m][n])
98+
i, j := m, n
99+
for i > 0 && j > 0 {
100+
if a[i-1] == b[j-1] {
101+
lcs = append(lcs, a[i-1])
102+
i--
103+
j--
104+
} else if dp[i-1][j] >= dp[i][j-1] {
105+
i--
106+
} else {
107+
j--
108+
}
109+
}
110+
// Reverse
111+
for left, right := 0, len(lcs)-1; left < right; left, right = left+1, right-1 {
112+
lcs[left], lcs[right] = lcs[right], lcs[left]
113+
}
114+
return lcs
115+
}
116+
117+
// groupHunks groups consecutive edits into hunks separated by context lines.
118+
func groupHunks(edits []edit, ctx int) [][]edit {
119+
if len(edits) == 0 {
120+
return nil
121+
}
122+
123+
// Find changed regions (non-context edits)
124+
type region struct{ start, end int }
125+
var regions []region
126+
for i, e := range edits {
127+
if e.op != ' ' {
128+
if len(regions) == 0 || i > regions[len(regions)-1].end+1 {
129+
regions = append(regions, region{i, i})
130+
} else {
131+
regions[len(regions)-1].end = i
132+
}
133+
}
134+
}
135+
136+
if len(regions) == 0 {
137+
return nil
138+
}
139+
140+
// Merge regions that are within 2*ctx of each other, then add context
141+
var hunks [][]edit
142+
hunkStart := regions[0].start - ctx
143+
if hunkStart < 0 {
144+
hunkStart = 0
145+
}
146+
hunkEnd := regions[0].end + ctx
147+
if hunkEnd >= len(edits) {
148+
hunkEnd = len(edits) - 1
149+
}
150+
151+
for i := 1; i < len(regions); i++ {
152+
nextStart := regions[i].start - ctx
153+
if nextStart < 0 {
154+
nextStart = 0
155+
}
156+
if nextStart <= hunkEnd+1 {
157+
hunkEnd = regions[i].end + ctx
158+
if hunkEnd >= len(edits) {
159+
hunkEnd = len(edits) - 1
160+
}
161+
} else {
162+
hunks = append(hunks, edits[hunkStart:hunkEnd+1])
163+
hunkStart = nextStart
164+
hunkEnd = regions[i].end + ctx
165+
if hunkEnd >= len(edits) {
166+
hunkEnd = len(edits) - 1
167+
}
168+
}
169+
}
170+
hunks = append(hunks, edits[hunkStart:hunkEnd+1])
171+
return hunks
172+
}
173+
174+
// hunkHeader computes the old/new start line and count for a hunk.
175+
func hunkHeader(hunk []edit, allEdits []edit) (int, int, int, int) {
176+
// Find position of this hunk in the full edit list
177+
hunkStart := -1
178+
for i := range allEdits {
179+
if len(hunk) > 0 && &allEdits[i] == &hunk[0] {
180+
hunkStart = i
181+
break
182+
}
183+
}
184+
185+
// Count old and new lines before the hunk to get starting line numbers
186+
oldLine := 1
187+
newLine := 1
188+
for i := 0; i < hunkStart && i < len(allEdits); i++ {
189+
switch allEdits[i].op {
190+
case ' ':
191+
oldLine++
192+
newLine++
193+
case '-':
194+
oldLine++
195+
case '+':
196+
newLine++
197+
}
198+
}
199+
200+
oldCount := 0
201+
newCount := 0
202+
for _, e := range hunk {
203+
switch e.op {
204+
case ' ':
205+
oldCount++
206+
newCount++
207+
case '-':
208+
oldCount++
209+
case '+':
210+
newCount++
211+
}
212+
}
213+
214+
return oldLine, oldCount, newLine, newCount
215+
}

0 commit comments

Comments
 (0)