Side-by-side semantic diff tool with theme support and hookable integration
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: character-level diff highlights

+132 -16
+29
internal/diff.go
··· 329 329 return result 330 330 } 331 331 332 + // CharDiffRanges computes a character-level diff between oldText and newText and 333 + // returns the rune-position ranges [start, end) in each string that differ. 334 + // Ranges are sorted and non-overlapping. 335 + func CharDiffRanges(oldText, newText string) (oldRanges, newRanges [][2]int) { 336 + if oldText == newText { 337 + return nil, nil 338 + } 339 + dmp := diffmatchpatch.New() 340 + diffs := dmp.DiffMain(oldText, newText, false) 341 + diffs = dmp.DiffCleanupSemantic(diffs) 342 + 343 + oldPos, newPos := 0, 0 344 + for _, d := range diffs { 345 + n := len([]rune(d.Text)) 346 + switch d.Type { 347 + case diffmatchpatch.DiffEqual: 348 + oldPos += n 349 + newPos += n 350 + case diffmatchpatch.DiffDelete: 351 + oldRanges = append(oldRanges, [2]int{oldPos, oldPos + n}) 352 + oldPos += n 353 + case diffmatchpatch.DiffInsert: 354 + newRanges = append(newRanges, [2]int{newPos, newPos + n}) 355 + newPos += n 356 + } 357 + } 358 + return 359 + } 360 + 332 361 // SplitLines splits text by newlines, keeping each line without its terminator. 333 362 func SplitLines(s string) []string { 334 363 if s == "" {
+69
internal/highlight.go
··· 4 4 "fmt" 5 5 "io" 6 6 "strings" 7 + "unicode/utf8" 7 8 8 9 "github.com/alecthomas/chroma/v2" 9 10 "github.com/alecthomas/chroma/v2/lexers" ··· 109 110 return bg + content + padding + bg + "\033[K\033[0m" 110 111 } 111 112 return bg + content + padding + "\033[0m" 113 + } 114 + 115 + // ApplyCharHighlights inserts background-color transitions into an ANSI-highlighted 116 + // string so that the visible rune positions covered by ranges use charBg instead of 117 + // normalBg. The caller is expected to set normalBg as the active background before 118 + // this string is rendered (e.g. RenderCell prepends it). 119 + // ranges must be sorted and contain non-overlapping [start, end) rune positions. 120 + func ApplyCharHighlights(highlighted, normalBg, charBg string, ranges [][2]int) string { 121 + if len(ranges) == 0 || charBg == "" || normalBg == "" { 122 + return highlighted 123 + } 124 + nr, ng, nb := parseHex(normalBg) 125 + cr, cg, cb := parseHex(charBg) 126 + normalCode := fmt.Sprintf("\033[48;2;%d;%d;%dm", nr, ng, nb) 127 + charCode := fmt.Sprintf("\033[48;2;%d;%d;%dm", cr, cg, cb) 128 + 129 + inRange := func(pos int) bool { 130 + for _, r := range ranges { 131 + if r[0] > pos { 132 + return false 133 + } 134 + if pos < r[1] { 135 + return true 136 + } 137 + } 138 + return false 139 + } 140 + 141 + var out strings.Builder 142 + out.Grow(len(highlighted) + len(ranges)*2*(len(charCode)+len(normalCode))) 143 + visiblePos := 0 144 + inHL := false 145 + 146 + i := 0 147 + for i < len(highlighted) { 148 + // Skip ANSI escape sequences verbatim. 149 + if highlighted[i] == '\x1b' && i+1 < len(highlighted) && highlighted[i+1] == '[' { 150 + j := i + 2 151 + for j < len(highlighted) && highlighted[j] != 'm' { 152 + j++ 153 + } 154 + if j < len(highlighted) { 155 + j++ // include 'm' 156 + } 157 + out.WriteString(highlighted[i:j]) 158 + i = j 159 + continue 160 + } 161 + 162 + want := inRange(visiblePos) 163 + if want && !inHL { 164 + out.WriteString(charCode) 165 + inHL = true 166 + } else if !want && inHL { 167 + out.WriteString(normalCode) 168 + inHL = false 169 + } 170 + 171 + _, size := utf8.DecodeRuneInString(highlighted[i:]) 172 + out.WriteString(highlighted[i : i+size]) 173 + visiblePos++ 174 + i += size 175 + } 176 + 177 + if inHL { 178 + out.WriteString(normalCode) 179 + } 180 + return out.String() 112 181 } 113 182 114 183 // parseHex parses a "#RRGGBB" hex colour string into R, G, B components.
+22 -16
internal/theme.go
··· 29 29 type DiffColors struct { 30 30 AddedBg string `toml:"added_bg"` 31 31 AddedFg string `toml:"added_fg"` 32 + AddedCharBg string `toml:"added_char_bg"` 32 33 RemovedBg string `toml:"removed_bg"` 33 34 RemovedFg string `toml:"removed_fg"` 35 + RemovedCharBg string `toml:"removed_char_bg"` 34 36 EqualFg string `toml:"equal_fg"` 35 37 HeaderFg string `toml:"header_fg"` 36 38 LineNumberFg string `toml:"line_number_fg"` ··· 46 48 } 47 49 48 50 type ThemeStyles struct { 49 - HeaderSt lipgloss.Style 50 - LineSt lipgloss.Style 51 - StatusSt lipgloss.Style 52 - HelpSt lipgloss.Style 53 - AddedBgHex string // raw hex for composing with syntax-highlighted text 54 - RemovedBgHex string 55 - ChromaStyle string 56 - CursorSymbol string 51 + HeaderSt lipgloss.Style 52 + LineSt lipgloss.Style 53 + StatusSt lipgloss.Style 54 + HelpSt lipgloss.Style 55 + AddedBgHex string // raw hex for composing with syntax-highlighted text 56 + AddedCharBgHex string // brighter bg for intra-line added characters 57 + RemovedBgHex string 58 + RemovedCharBgHex string // brighter bg for intra-line removed characters 59 + ChromaStyle string 60 + CursorSymbol string 57 61 } 58 62 59 63 // ── loader ──────────────────────────────────────────────────────────────────── ··· 93 97 func (t Theme) ToStyles() ThemeStyles { 94 98 d := t.Diff 95 99 return ThemeStyles{ 96 - HeaderSt: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(d.HeaderFg)), 97 - LineSt: lipgloss.NewStyle().Foreground(lipgloss.Color(d.LineNumberFg)), 98 - StatusSt: lipgloss.NewStyle().Foreground(lipgloss.Color(d.StatusFg)).Bold(true), 99 - HelpSt: lipgloss.NewStyle().Foreground(lipgloss.Color(d.HelpFg)), 100 - AddedBgHex: d.AddedBg, 101 - RemovedBgHex: d.RemovedBg, 102 - ChromaStyle: t.Chroma.Style, 103 - CursorSymbol: d.CursorSymbol, 100 + HeaderSt: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(d.HeaderFg)), 101 + LineSt: lipgloss.NewStyle().Foreground(lipgloss.Color(d.LineNumberFg)), 102 + StatusSt: lipgloss.NewStyle().Foreground(lipgloss.Color(d.StatusFg)).Bold(true), 103 + HelpSt: lipgloss.NewStyle().Foreground(lipgloss.Color(d.HelpFg)), 104 + AddedBgHex: d.AddedBg, 105 + AddedCharBgHex: d.AddedCharBg, 106 + RemovedBgHex: d.RemovedBg, 107 + RemovedCharBgHex: d.RemovedCharBg, 108 + ChromaStyle: t.Chroma.Style, 109 + CursorSymbol: d.CursorSymbol, 104 110 } 105 111 }
+2
internal/themes/nord.toml
··· 9 9 [diff] 10 10 added_bg = "#2E4A38" 11 11 added_fg = "#A3BE8C" 12 + added_char_bg = "#4F8A5C" 12 13 removed_bg = "#3D1F1F" 13 14 removed_fg = "#BF616A" 15 + removed_char_bg = "#8A3333" 14 16 equal_fg = "#D8DEE9" 15 17 header_fg = "#88C0D0" 16 18 line_number_fg = "#4C566A"
+10
main.go
··· 445 445 case adiff.KindChanged: 446 446 lBgHex = st.RemovedBgHex 447 447 rBgHex = st.AddedBgHex 448 + if st.RemovedCharBgHex != "" && st.AddedCharBgHex != "" { 449 + lRanges, rRanges := adiff.CharDiffRanges(adiff.StripANSI(leftLine), adiff.StripANSI(rightLine)) 450 + leftLine = adiff.ApplyCharHighlights(leftLine, lBgHex, st.RemovedCharBgHex, lRanges) 451 + rightLine = adiff.ApplyCharHighlights(rightLine, rBgHex, st.AddedCharBgHex, rRanges) 452 + } 448 453 } 449 454 450 455 prefix := " " ··· 583 588 case adiff.KindChanged: 584 589 lBgHex = st.RemovedBgHex 585 590 rBgHex = st.AddedBgHex 591 + if st.RemovedCharBgHex != "" && st.AddedCharBgHex != "" { 592 + lRanges, rRanges := adiff.CharDiffRanges(adiff.StripANSI(leftLine), adiff.StripANSI(rightLine)) 593 + leftLine = adiff.ApplyCharHighlights(leftLine, lBgHex, st.RemovedCharBgHex, lRanges) 594 + rightLine = adiff.ApplyCharHighlights(rightLine, rBgHex, st.AddedCharBgHex, rRanges) 595 + } 586 596 } 587 597 588 598 lNum := " "