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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
label string
|
||||
raw string
|
||||
@@ -39,8 +168,9 @@ const (
|
||||
)
|
||||
|
||||
type diffLine struct {
|
||||
text string
|
||||
kind lineKind
|
||||
text string // displayed text (highlighted, possibly word-diff decorated)
|
||||
plainText string // plain text for word-diff pairing (empty for padding lines)
|
||||
kind lineKind
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
@@ -127,6 +257,7 @@ func (m *Model) computeDiff() {
|
||||
leftHL := hlLines(leftNorm)
|
||||
rightHL := hlLines(rightNorm)
|
||||
m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL)
|
||||
applyWordDiff(m.leftLines, m.rightLines)
|
||||
}
|
||||
|
||||
func normRaw(s string) string {
|
||||
@@ -228,10 +359,10 @@ func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) {
|
||||
j--
|
||||
case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]):
|
||||
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--
|
||||
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})
|
||||
i--
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user