Skip to content

Commit 56efc6b

Browse files
authored
feat: add syntax highlighting to diff viewer (#3)
1 parent f31d0ed commit 56efc6b

419 files changed

Lines changed: 69919 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.26.0
55
require (
66
charm.land/bubbletea/v2 v2.0.0
77
charm.land/lipgloss/v2 v2.0.0
8+
github.com/alecthomas/chroma/v2 v2.23.1
89
github.com/charmbracelet/x/ansi v0.11.6
910
github.com/google/go-cmp v0.7.0
1011
github.com/urfave/cli/v3 v3.6.1
@@ -19,6 +20,7 @@ require (
1920
github.com/charmbracelet/x/windows v0.2.2 // indirect
2021
github.com/clipperhouse/displaywidth v0.11.0 // indirect
2122
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
23+
github.com/dlclark/regexp2 v1.11.5 // indirect
2224
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2325
github.com/mattn/go-runewidth v0.0.19 // indirect
2426
github.com/muesli/cancelreader v0.2.2 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
22
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
33
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
44
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
5+
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
6+
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
7+
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
8+
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
9+
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
10+
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
511
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
612
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
713
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
@@ -24,8 +30,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
2430
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
2531
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2632
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
33+
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
34+
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
2735
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2836
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
37+
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
38+
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
2939
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
3040
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
3141
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=

tui/diffview.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ type diffViewModel struct {
2424
height int
2525
focused bool
2626
renderedLen int
27+
hl *highlighter
28+
hlCache []string
2729
}
2830

29-
func newDiffViewModel() diffViewModel {
31+
func newDiffViewModel(hl *highlighter) diffViewModel {
3032
return diffViewModel{
3133
detail: nil,
3234
activeHunk: 0,
@@ -35,6 +37,8 @@ func newDiffViewModel() diffViewModel {
3537
height: defaultDiffHeight,
3638
focused: false,
3739
renderedLen: 0,
40+
hl: hl,
41+
hlCache: nil,
3842
}
3943
}
4044

@@ -43,6 +47,12 @@ func (m *diffViewModel) setDetail(d *versioncontrol.ChangeDetail) {
4347
m.activeHunk = 0
4448
m.scrollY = 0
4549
m.renderedLen = m.computeRenderedLen()
50+
51+
if d != nil {
52+
m.hlCache = m.hl.highlightFile(d.File, d.Path)
53+
} else {
54+
m.hlCache = nil
55+
}
4656
}
4757

4858
func (m *diffViewModel) computeRenderedLen() int {
@@ -173,6 +183,8 @@ func (m *diffViewModel) render() string {
173183
func (m *diffViewModel) renderHunks() []string {
174184
var allLines []string
175185

186+
lineIdx := 0
187+
176188
for hunkIdx, hunk := range m.detail.File.Hunks {
177189
isActive := hunkIdx == m.activeHunk && m.focused
178190
isStaged := m.detail.Hunks[hunkIdx].Staged
@@ -183,11 +195,13 @@ func (m *diffViewModel) renderHunks() []string {
183195
}
184196

185197
allLines = append(allLines, headerPrefix+hunkHeaderStyle().Render(hunk.Header))
198+
lineIdx++ // hunk header
186199

187200
border := m.hunkBorder(isActive, isStaged)
188201

189202
for _, line := range hunk.Lines {
190-
allLines = append(allLines, border+m.styleLine(line))
203+
allLines = append(allLines, border+m.styleLine(line, lineIdx))
204+
lineIdx++
191205
}
192206
}
193207

@@ -217,7 +231,18 @@ func (m *diffViewModel) clampScroll(totalLines int) {
217231
}
218232
}
219233

220-
func (m *diffViewModel) styleLine(line diff.Line) string {
234+
func (m *diffViewModel) styleLine(line diff.Line, lineIdx int) string {
235+
plain := m.plainStyleLine(line)
236+
237+
var highlight string
238+
if lineIdx >= 0 && lineIdx < len(m.hlCache) {
239+
highlight = m.hlCache[lineIdx]
240+
}
241+
242+
return m.hl.styleLine(line, highlight, plain)
243+
}
244+
245+
func (m *diffViewModel) plainStyleLine(line diff.Line) string {
221246
switch line.Type {
222247
case diff.Added:
223248
return addedStyle().Render(line.Content)

tui/highlight.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/alecthomas/chroma/v2"
8+
"github.com/alecthomas/chroma/v2/lexers"
9+
"github.com/alecthomas/chroma/v2/styles"
10+
"github.com/nhost/lazyreview/diff"
11+
)
12+
13+
// highlighter tokenizes diff lines using chroma and caches the results per file.
14+
type highlighter struct {
15+
cache map[string][]string // file path -> highlighted lines
16+
style *chroma.Style
17+
}
18+
19+
// newHighlighter creates a highlighter with the dracula chroma style.
20+
func newHighlighter() *highlighter {
21+
return &highlighter{
22+
cache: make(map[string][]string),
23+
style: styles.Get("dracula"),
24+
}
25+
}
26+
27+
// clear invalidates the entire highlight cache.
28+
func (h *highlighter) clear() {
29+
h.cache = make(map[string][]string)
30+
}
31+
32+
// highlightFile tokenizes all lines in a diff file and returns cached results.
33+
// Returns nil if no lexer matches the file path.
34+
func (h *highlighter) highlightFile(f *diff.File, path string) []string {
35+
if f == nil {
36+
return nil
37+
}
38+
39+
if cached, ok := h.cache[path]; ok {
40+
return cached
41+
}
42+
43+
lexer := lexers.Match(path)
44+
if lexer == nil {
45+
return nil
46+
}
47+
48+
lexer = chroma.Coalesce(lexer)
49+
50+
var lines []string
51+
for _, hunk := range f.Hunks {
52+
// blank entry for the hunk header line
53+
lines = append(lines, "")
54+
55+
for _, line := range hunk.Lines {
56+
lines = append(lines, h.highlightLine(lexer, line.Content))
57+
}
58+
}
59+
60+
h.cache[path] = lines
61+
62+
return lines
63+
}
64+
65+
// highlightLine tokenizes a single line and returns an ANSI-colored string.
66+
func (h *highlighter) highlightLine(lexer chroma.Lexer, content string) string {
67+
// Strip the diff prefix (+/-/ ) for tokenization
68+
raw := content
69+
if len(raw) > 0 {
70+
switch raw[0] {
71+
case '+', '-', ' ':
72+
raw = raw[1:]
73+
}
74+
}
75+
76+
iter, err := lexer.Tokenise(nil, raw)
77+
if err != nil {
78+
return ""
79+
}
80+
81+
var sb strings.Builder
82+
83+
// Write the diff prefix character without syntax coloring
84+
if len(content) > 0 {
85+
sb.WriteByte(content[0])
86+
}
87+
88+
for _, token := range iter.Tokens() {
89+
entry := h.style.Get(token.Type)
90+
fg := entry.Colour
91+
92+
if !fg.IsSet() {
93+
sb.WriteString(token.Value)
94+
95+
continue
96+
}
97+
98+
r, g, b := fg.Red(), fg.Green(), fg.Blue()
99+
sb.WriteString(fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[39m", r, g, b, token.Value))
100+
}
101+
102+
return sb.String()
103+
}
104+
105+
// styleLine combines a cached syntax-highlighted foreground with a diff
106+
// background color. Falls back to plain diff styling when highlight is empty.
107+
func (h *highlighter) styleLine(
108+
line diff.Line,
109+
highlight string,
110+
plainFallback string,
111+
) string {
112+
if highlight == "" {
113+
return plainFallback
114+
}
115+
116+
switch line.Type {
117+
case diff.Added:
118+
return addedBgANSI + highlight + resetANSI
119+
case diff.Removed:
120+
return removedBgANSI + highlight + resetANSI
121+
case diff.Context:
122+
return highlight
123+
}
124+
125+
return highlight
126+
}

tui/model.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ type Model struct { //nolint:recvcheck
161161
diffView diffViewModel
162162
help helpModel
163163
commit commitModel
164+
hl *highlighter
164165
review View
165166
git GitView
166167
active int
@@ -194,8 +195,9 @@ func NewModel(
194195
cfg = gitViewConfig()
195196
}
196197

198+
hl := newHighlighter()
197199
ft := newFileTreeModel(initialStatuses, cfg)
198-
dv := newDiffViewModel()
200+
dv := newDiffViewModel(hl)
199201
h := newHelpModel(activeIdx == 1)
200202
c := newCommitModel()
201203

@@ -204,6 +206,7 @@ func NewModel(
204206
diffView: dv,
205207
help: h,
206208
commit: c,
209+
hl: hl,
207210
review: rv,
208211
git: gv,
209212
active: activeIdx,
@@ -308,6 +311,8 @@ func (m Model) handleRefreshDone(msg refreshDoneMsg) Model {
308311
return m
309312
}
310313

314+
m.hl.clear()
315+
311316
savedHunk := m.diffView.activeHunk
312317
expandedPaths := m.fileTree.expandedPaths()
313318
selectedPath := m.fileTree.selectedPath()

tui/styles.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ const (
99
commitOverlayWidth = 60
1010
)
1111

12+
// ANSI background escape codes for syntax-highlighted diff lines.
13+
const (
14+
addedBgANSI = "\033[48;2;20;40;20m" // very dark green background
15+
removedBgANSI = "\033[48;2;45;18;18m" // very dark red background
16+
resetANSI = "\033[0m"
17+
)
18+
1219
func panelStyle(focused bool) lipgloss.Style {
1320
borderColor := lipgloss.Color("240")
1421
if focused {

vendor/github.com/alecthomas/chroma/v2/.editorconfig

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/alecthomas/chroma/v2/.gitignore

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)