mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 01:32:33 +02:00
[37mfeat: word-level diff highlighting in diff view[0m
[37m- tokenize() splits lines into word-char runs and single non-word bytes[0m [37m- wordDiff() runs LCS on tokens and renders changed tokens with bold colors[0m [37m- applyWordDiff() post-processes equal-size removed/added line blocks[0m [37m- lcsAlignedDiff now stores plainText on removed/added lines for pairing[0m [37m- Unchanged tokens rendered dim; removed tokens bold-red; added tokens bold-green[0m [37mCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>[0m
This commit is contained in:
+135
-4
@@ -13,6 +13,135 @@ import (
|
|||||||
"github.com/anotherhadi/spilltea/internal/util"
|
"github.com/anotherhadi/spilltea/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isWordChar reports whether c belongs to a "word" token (letter, digit, underscore).
|
||||||
|
func isWordChar(c byte) bool {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits s into runs of word characters and individual non-word bytes.
|
||||||
|
func tokenize(s string) []string {
|
||||||
|
var out []string
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if isWordChar(s[i]) {
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isWordChar(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
out = append(out, s[i:j])
|
||||||
|
i = j
|
||||||
|
} else {
|
||||||
|
out = append(out, s[i:i+1])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordDiff computes a token-level diff between leftLine and rightLine and
|
||||||
|
// returns the two rendered strings with changed tokens highlighted.
|
||||||
|
func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) {
|
||||||
|
lToks := tokenize(leftLine)
|
||||||
|
rToks := tokenize(rightLine)
|
||||||
|
|
||||||
|
n, m := len(lToks), len(rToks)
|
||||||
|
dp := make([][]int, n+1)
|
||||||
|
for i := range dp {
|
||||||
|
dp[i] = make([]int, m+1)
|
||||||
|
}
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
for j := 1; j <= m; j++ {
|
||||||
|
if lToks[i-1] == rToks[j-1] {
|
||||||
|
dp[i][j] = dp[i-1][j-1] + 1
|
||||||
|
} else if dp[i-1][j] >= dp[i][j-1] {
|
||||||
|
dp[i][j] = dp[i-1][j]
|
||||||
|
} else {
|
||||||
|
dp[i][j] = dp[i][j-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type segment struct {
|
||||||
|
kind int // 0=same, 1=left-only, 2=right-only
|
||||||
|
tok string
|
||||||
|
}
|
||||||
|
segs := make([]segment, 0, n+m)
|
||||||
|
i, j := n, m
|
||||||
|
for i > 0 || j > 0 {
|
||||||
|
switch {
|
||||||
|
case i > 0 && j > 0 && lToks[i-1] == rToks[j-1]:
|
||||||
|
segs = append(segs, segment{0, lToks[i-1]})
|
||||||
|
i--
|
||||||
|
j--
|
||||||
|
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||||
|
segs = append(segs, segment{2, rToks[j-1]})
|
||||||
|
j--
|
||||||
|
default:
|
||||||
|
segs = append(segs, segment{1, lToks[i-1]})
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for lo, hi := 0, len(segs)-1; lo < hi; lo, hi = lo+1, hi-1 {
|
||||||
|
segs[lo], segs[hi] = segs[hi], segs[lo]
|
||||||
|
}
|
||||||
|
|
||||||
|
s := style.S
|
||||||
|
boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true)
|
||||||
|
boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true)
|
||||||
|
dim := lipgloss.NewStyle().Foreground(s.Subtle)
|
||||||
|
|
||||||
|
var lb, rb strings.Builder
|
||||||
|
for _, seg := range segs {
|
||||||
|
switch seg.kind {
|
||||||
|
case 0:
|
||||||
|
lb.WriteString(dim.Render(seg.tok))
|
||||||
|
rb.WriteString(dim.Render(seg.tok))
|
||||||
|
case 1:
|
||||||
|
lb.WriteString(boldErr.Render(seg.tok))
|
||||||
|
case 2:
|
||||||
|
rb.WriteString(boldOk.Render(seg.tok))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lb.String(), rb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWordDiff post-processes line-level diff arrays to apply token-level
|
||||||
|
// highlighting to equal-sized blocks of removed/added lines.
|
||||||
|
func applyWordDiff(left, right []diffLine) {
|
||||||
|
i := 0
|
||||||
|
for i < len(left) {
|
||||||
|
if left[i].kind != lineRemoved {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rStart := i
|
||||||
|
for i < len(left) && left[i].kind == lineRemoved {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
rEnd := i
|
||||||
|
aStart := i
|
||||||
|
for i < len(left) && left[i].kind == lineAdded {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
aEnd := i
|
||||||
|
|
||||||
|
nRemoved := rEnd - rStart
|
||||||
|
nAdded := aEnd - aStart
|
||||||
|
if nRemoved == 0 || nAdded == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs := nRemoved
|
||||||
|
if nAdded < pairs {
|
||||||
|
pairs = nAdded
|
||||||
|
}
|
||||||
|
for k := 0; k < pairs; k++ {
|
||||||
|
lText, rText := wordDiff(left[rStart+k].plainText, right[aStart+k].plainText)
|
||||||
|
left[rStart+k].text = lText
|
||||||
|
right[aStart+k].text = rText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type slot struct {
|
type slot struct {
|
||||||
label string
|
label string
|
||||||
raw string
|
raw string
|
||||||
@@ -39,8 +168,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type diffLine struct {
|
type diffLine struct {
|
||||||
text string
|
text string // displayed text (highlighted, possibly word-diff decorated)
|
||||||
kind lineKind
|
plainText string // plain text for word-diff pairing (empty for padding lines)
|
||||||
|
kind lineKind
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -127,6 +257,7 @@ func (m *Model) computeDiff() {
|
|||||||
leftHL := hlLines(leftNorm)
|
leftHL := hlLines(leftNorm)
|
||||||
rightHL := hlLines(rightNorm)
|
rightHL := hlLines(rightNorm)
|
||||||
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
||||||
|
applyWordDiff(m.leftLines, m.rightLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normRaw(s string) string {
|
func normRaw(s string) string {
|
||||||
@@ -228,10 +359,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
|||||||
j--
|
j--
|
||||||
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||||
left = append(left, diffLine{kind: lineAdded})
|
left = append(left, diffLine{kind: lineAdded})
|
||||||
right = append(right, diffLine{text: hlB(j - 1), kind: lineAdded})
|
right = append(right, diffLine{text: hlB(j - 1), plainText: b[j-1], kind: lineAdded})
|
||||||
j--
|
j--
|
||||||
default:
|
default:
|
||||||
left = append(left, diffLine{text: hlA(i - 1), kind: lineRemoved})
|
left = append(left, diffLine{text: hlA(i - 1), plainText: a[i-1], kind: lineRemoved})
|
||||||
right = append(right, diffLine{kind: lineRemoved})
|
right = append(right, diffLine{kind: lineRemoved})
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user