Skip to content

Commit 8677b91

Browse files
authored
Merge pull request #2900 from rumpl/improve-diff-view
feat(tui): word-level highlighting in edit_file diff view
2 parents 6fd7cef + 39dc76a commit 8677b91

7 files changed

Lines changed: 645 additions & 15 deletions

File tree

pkg/tui/components/tool/editfile/render.go

Lines changed: 167 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ func InvalidateCaches() {
7171
type chromaToken struct {
7272
Text string
7373
Style lipgloss.Style
74+
// Emphasized marks tokens whose underlying text was identified as part
75+
// of a word-level diff. Renderers paint these with a stronger background
76+
// so users can locate the precise edit within a long line.
77+
Emphasized bool
7478
}
7579

7680
type linePair struct {
@@ -310,18 +314,111 @@ func chromaToLipgloss(tokenType chroma.TokenType, style *chroma.Style) lipgloss.
310314
return lipStyle
311315
}
312316

317+
// segRange records the byte extent of a single word-diff segment within the
318+
// full line, plus whether that extent represents a change.
319+
type segRange struct {
320+
start, end int
321+
changed bool
322+
}
323+
324+
// applyWordEmphasis re-tags chroma tokens so that any portion of the token
325+
// text that falls inside a "changed" word-diff segment is split off into its
326+
// own emphasized token. Token boundaries from chroma and from the word-diff
327+
// rarely line up, so each chroma token is sliced against the segment cursor
328+
// to produce correctly emphasized sub-tokens.
329+
func applyWordEmphasis(tokens []chromaToken, segs []wordSegment) []chromaToken {
330+
if len(segs) == 0 {
331+
return tokens
332+
}
333+
334+
ranges := make([]segRange, 0, len(segs))
335+
pos := 0
336+
for _, s := range segs {
337+
ranges = append(ranges, segRange{start: pos, end: pos + len(s.Text), changed: s.Changed})
338+
pos += len(s.Text)
339+
}
340+
341+
out := make([]chromaToken, 0, len(tokens))
342+
bytePos := 0
343+
for _, tok := range tokens {
344+
text := tok.Text
345+
tokStart := bytePos
346+
tokEnd := bytePos + len(text)
347+
348+
cursor := tokStart
349+
for cursor < tokEnd {
350+
r := findSegRange(ranges, cursor)
351+
if r == nil {
352+
out = append(out, chromaToken{
353+
Text: text[cursor-tokStart:],
354+
Style: tok.Style,
355+
})
356+
break
357+
}
358+
end := min(tokEnd, r.end)
359+
sub := text[cursor-tokStart : end-tokStart]
360+
if sub != "" {
361+
out = append(out, chromaToken{
362+
Text: sub,
363+
Style: tok.Style,
364+
Emphasized: r.changed,
365+
})
366+
}
367+
cursor = end
368+
}
369+
bytePos = tokEnd
370+
}
371+
372+
return out
373+
}
374+
375+
func findSegRange(ranges []segRange, pos int) *segRange {
376+
for i := range ranges {
377+
if pos >= ranges[i].start && pos < ranges[i].end {
378+
return &ranges[i]
379+
}
380+
}
381+
return nil
382+
}
383+
384+
// emphasisStyleFor returns the per-side emphasis style (with a stronger
385+
// background tint) for the row's diff kind. Returns the unchanged style for
386+
// kinds that should never carry word-level emphasis.
387+
func emphasisStyleFor(kind udiff.OpKind) lipgloss.Style {
388+
switch kind {
389+
case udiff.Delete:
390+
return styles.DiffRemoveEmphStyle
391+
case udiff.Insert:
392+
return styles.DiffAddEmphStyle
393+
default:
394+
return styles.DiffUnchangedStyle
395+
}
396+
}
397+
313398
func renderDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width int) string {
314399
var output strings.Builder
315400
contentWidth := width - lineNumWidth
316401

317402
for _, hunk := range diff {
403+
// Build word-diff lookups for paired delete/insert lines so we can
404+
// emphasize the precise tokens that changed.
405+
wordDiffs := buildLineWordDiffs(hunk.Lines)
406+
318407
oldLineNum := hunk.FromLine
319408
newLineNum := hunk.ToLine
320409

321-
for _, line := range hunk.Lines {
410+
for li, line := range hunk.Lines {
322411
lineNum := getDisplayLineNumber(&line, &oldLineNum, &newLineNum)
323412
content := prepareContent(line.Content)
324413
tokens := syntaxHighlight(content, filePath)
414+
if wd, ok := wordDiffs[li]; ok {
415+
switch line.Kind {
416+
case udiff.Delete:
417+
tokens = applyWordEmphasis(tokens, wd.old)
418+
case udiff.Insert:
419+
tokens = applyWordEmphasis(tokens, wd.new)
420+
}
421+
}
325422
lineStyle := getLineStyle(line.Kind)
326423
wrappedTokens := wrapTokens(tokens, contentWidth)
327424

@@ -334,7 +431,7 @@ func renderDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width in
334431
// Use continuation indicator for wrapped lines
335432
lineNumStr = styles.LineNumberStyle.Render(" → ")
336433
}
337-
rendered := renderTokensWithStyle(tokenLine, lineStyle)
434+
rendered := renderTokensWithStyle(tokenLine, lineStyle, line.Kind)
338435
padded := padToWidth(rendered, contentWidth, lineStyle)
339436
output.WriteString(lineNumStr + padded + "\n")
340437
}
@@ -358,8 +455,22 @@ func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, wid
358455

359456
for _, hunk := range diff {
360457
for _, pair := range pairDiffLines(hunk.Lines, hunk.FromLine, hunk.ToLine) {
361-
leftLines := renderSplitSide(pair.old, pair.oldLineNum, filePath, contentWidth)
362-
rightLines := renderSplitSide(pair.new, pair.newLineNum, filePath, contentWidth)
458+
// Word-diff is only meaningful when both halves are present
459+
// and represent a delete/insert pair. Inputs go through
460+
// prepareContent so the segment byte offsets line up with the
461+
// chroma tokens produced for rendering (which also receive
462+
// tab-expanded content).
463+
var oldSegs, newSegs []wordSegment
464+
if pair.old != nil && pair.new != nil &&
465+
pair.old.Kind == udiff.Delete && pair.new.Kind == udiff.Insert {
466+
oldSegs, newSegs = diffWords(
467+
prepareContent(pair.old.Content),
468+
prepareContent(pair.new.Content),
469+
)
470+
}
471+
472+
leftLines := renderSplitSide(pair.old, pair.oldLineNum, filePath, contentWidth, oldSegs)
473+
rightLines := renderSplitSide(pair.new, pair.newLineNum, filePath, contentWidth, newSegs)
363474

364475
// Ensure both sides have the same number of lines for alignment
365476
maxLines := max(len(rightLines), len(leftLines))
@@ -383,6 +494,32 @@ func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, wid
383494
return strings.TrimSuffix(output.String(), "\n")
384495
}
385496

497+
// lineWordDiff holds the per-side segment arrays computed for one
498+
// delete/insert pair. It is keyed by the *delete* line index — the matching
499+
// insert sits directly after it.
500+
type lineWordDiff struct {
501+
old []wordSegment
502+
new []wordSegment
503+
}
504+
505+
func buildLineWordDiffs(lines []udiff.Line) map[int]lineWordDiff {
506+
out := map[int]lineWordDiff{}
507+
for i := range len(lines) - 1 {
508+
if lines[i].Kind != udiff.Delete || lines[i+1].Kind != udiff.Insert {
509+
continue
510+
}
511+
// Use prepareContent so segment offsets align with the chroma
512+
// tokens (which are produced from the same tab-expanded text).
513+
oldText := prepareContent(lines[i].Content)
514+
newText := prepareContent(lines[i+1].Content)
515+
oldSegs, newSegs := diffWords(oldText, newText)
516+
wd := lineWordDiff{old: oldSegs, new: newSegs}
517+
out[i] = wd
518+
out[i+1] = wd
519+
}
520+
return out
521+
}
522+
386523
func getDisplayLineNumber(line *udiff.Line, oldLineNum, newLineNum *int) int {
387524
switch line.Kind {
388525
case udiff.Delete:
@@ -456,7 +593,11 @@ func wrapTokens(tokens []chromaToken, maxWidth int) [][]chromaToken {
456593
fitWidth = runewidth.RuneWidth(r)
457594
}
458595

459-
currentLine = append(currentLine, chromaToken{Text: text[:fitLen], Style: token.Style})
596+
currentLine = append(currentLine, chromaToken{
597+
Text: text[:fitLen],
598+
Style: token.Style,
599+
Emphasized: token.Emphasized,
600+
})
460601
currentWidth += fitWidth
461602
text = text[fitLen:]
462603
}
@@ -473,14 +614,20 @@ func wrapTokens(tokens []chromaToken, maxWidth int) [][]chromaToken {
473614
return lines
474615
}
475616

476-
// renderSplitSide renders a split side with text wrapping support
477-
func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int) []string {
617+
// renderSplitSide renders a split side with text wrapping support.
618+
// When wordSegs is non-nil and the line is part of a delete/insert pair, the
619+
// chroma tokens are re-tagged so the changed substrings render with a
620+
// stronger background tint.
621+
func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int, wordSegs []wordSegment) []string {
478622
if line == nil {
479623
return []string{renderEmptySplitSide(width)}
480624
}
481625

482626
content := prepareContent(line.Content)
483627
tokens := syntaxHighlight(content, filePath)
628+
if len(wordSegs) > 0 {
629+
tokens = applyWordEmphasis(tokens, wordSegs)
630+
}
484631
lineStyle := getLineStyle(line.Kind)
485632
wrappedTokens := wrapTokens(tokens, width)
486633

@@ -494,7 +641,7 @@ func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int)
494641
// Use continuation indicator for wrapped lines
495642
lineNumStr = " → "
496643
}
497-
rendered := renderTokensWithStyle(tokenLine, lineStyle)
644+
rendered := renderTokensWithStyle(tokenLine, lineStyle, line.Kind)
498645
padded := padToWidth(rendered, width, lineStyle)
499646
result = append(result, styles.LineNumberStyle.Render(lineNumStr)+padded)
500647
}
@@ -509,12 +656,21 @@ func renderEmptySplitSide(width int) string {
509656
return styles.LineNumberStyle.Render(lineNumStr) + emptySpace
510657
}
511658

512-
func renderTokensWithStyle(tokens []chromaToken, lineStyle lipgloss.Style) string {
659+
func renderTokensWithStyle(tokens []chromaToken, lineStyle lipgloss.Style, kind udiff.OpKind) string {
513660
var output strings.Builder
514661

662+
emph := emphasisStyleFor(kind)
515663
for _, token := range tokens {
516-
styledToken := token.Style.Background(lineStyle.GetBackground())
517-
output.WriteString(styledToken.Render(token.Text))
664+
if token.Emphasized && (kind == udiff.Delete || kind == udiff.Insert) {
665+
// Keep the chroma foreground so syntax colors carry through into
666+
// the emphasized block, but override the background with the
667+
// stronger emphasis tint and add bold for extra weight.
668+
style := token.Style.Background(emph.GetBackground()).Bold(true)
669+
output.WriteString(style.Render(token.Text))
670+
continue
671+
}
672+
style := token.Style.Background(lineStyle.GetBackground())
673+
output.WriteString(style.Render(token.Text))
518674
}
519675

520676
return output.String()
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package editfile
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/docker/docker-agent/pkg/tools"
14+
"github.com/docker/docker-agent/pkg/tui/types"
15+
)
16+
17+
// TestRenderEditFile_EndToEnd writes a temporary source file, builds an
18+
// edit_file tool call against it, and renders both unified and split views.
19+
// The test focuses on structural elements rather than exact escape sequences,
20+
// which depend on the active theme.
21+
func TestRenderEditFile_EndToEnd(t *testing.T) {
22+
dir := t.TempDir()
23+
path := filepath.Join(dir, "main.go")
24+
25+
updated := `package main
26+
27+
import "fmt"
28+
29+
func main() {
30+
x := 10
31+
y := 20
32+
fmt.Println(x + y)
33+
}
34+
`
35+
// Simulate the post-execution state: file already contains the new content.
36+
require.NoError(t, os.WriteFile(path, []byte(updated), 0o644))
37+
38+
args := map[string]any{
39+
"path": path,
40+
"edits": []map[string]string{
41+
{
42+
"oldText": "\tx := 1\n\ty := 2",
43+
"newText": "\tx := 10\n\ty := 20",
44+
},
45+
},
46+
}
47+
encoded, err := json.Marshal(args)
48+
require.NoError(t, err)
49+
50+
toolCall := tools.ToolCall{
51+
ID: "test-render-1",
52+
Function: tools.FunctionCall{
53+
Name: "edit_file",
54+
Arguments: string(encoded),
55+
},
56+
}
57+
58+
// Reset cache so the test is hermetic across runs.
59+
InvalidateCaches()
60+
t.Cleanup(InvalidateCaches)
61+
62+
unified := renderEditFile(toolCall, 120, false, types.ToolStatusCompleted)
63+
split := renderEditFile(toolCall, 120, true, types.ToolStatusCompleted)
64+
65+
for _, out := range []string{unified, split} {
66+
assert.NotEmpty(t, out)
67+
// Source content should appear in the diff regardless of theme escapes.
68+
assert.True(t, strings.Contains(out, "10") || strings.Contains(out, "20"))
69+
}
70+
71+
added, removed := countDiffLines(toolCall, types.ToolStatusCompleted)
72+
assert.Equal(t, 2, added)
73+
assert.Equal(t, 2, removed)
74+
}
75+
76+
func TestRenderEditFile_TabIndentedLineDoesNotPanic(t *testing.T) {
77+
// Regression: tab-indented modified lines used to feed raw (1-byte-tab)
78+
// text into diffWords while chroma tokens were built from the
79+
// tab-expanded variant, producing out-of-bounds slice indices in
80+
// applyWordEmphasis. The fix routes both through prepareContent.
81+
dir := t.TempDir()
82+
path := filepath.Join(dir, "main.go")
83+
84+
updated := "package main\n\nfunc main() {\n\tx := 10\n}\n"
85+
require.NoError(t, os.WriteFile(path, []byte(updated), 0o644))
86+
87+
args := map[string]any{
88+
"path": path,
89+
"edits": []map[string]string{
90+
{"oldText": "\tx := 1", "newText": "\tx := 10"},
91+
},
92+
}
93+
encoded, _ := json.Marshal(args)
94+
toolCall := tools.ToolCall{
95+
ID: "test-tab-1",
96+
Function: tools.FunctionCall{Name: "edit_file", Arguments: string(encoded)},
97+
}
98+
99+
InvalidateCaches()
100+
t.Cleanup(InvalidateCaches)
101+
102+
assert.NotPanics(t, func() {
103+
_ = renderEditFile(toolCall, 120, false, types.ToolStatusCompleted)
104+
_ = renderEditFile(toolCall, 120, true, types.ToolStatusCompleted)
105+
})
106+
}
107+
108+
func TestRenderEditFile_MissingFileReturnsEmptyDiff(t *testing.T) {
109+
args := map[string]any{
110+
"path": "/nonexistent/path/that/does/not/exist.go",
111+
"edits": []map[string]string{
112+
{"oldText": "a", "newText": "b"},
113+
},
114+
}
115+
encoded, _ := json.Marshal(args)
116+
toolCall := tools.ToolCall{
117+
ID: "test-missing-1",
118+
Function: tools.FunctionCall{Name: "edit_file", Arguments: string(encoded)},
119+
}
120+
121+
InvalidateCaches()
122+
t.Cleanup(InvalidateCaches)
123+
124+
// Should not panic on a missing source file.
125+
assert.NotPanics(t, func() {
126+
_ = renderEditFile(toolCall, 100, false, types.ToolStatusCompleted)
127+
})
128+
}

0 commit comments

Comments
 (0)