From 975d529908d66376e3b9419fbebb6ba9b5c37b16 Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Wed, 25 Feb 2026 08:49:29 +0100 Subject: [PATCH 1/3] initial syntax highlighting implementation --- go.mod | 2 + go.sum | 10 ++ internal/diffview/render.go | 193 +++++++++++++++++++++++++++++-- internal/diffview/render_test.go | 35 ++++++ 4 files changed, 229 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 1835e57..c0d8fa0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module diffman go 1.26.0 require ( + github.com/alecthomas/chroma/v2 v2.23.1 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/lipgloss v1.0.0 @@ -14,6 +15,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 3e06e04..cb54e90 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -12,10 +18,14 @@ github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSe github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/diffview/render.go b/internal/diffview/render.go index 5040dc5..973d5ab 100644 --- a/internal/diffview/render.go +++ b/internal/diffview/render.go @@ -2,9 +2,13 @@ package diffview import ( "fmt" + "path/filepath" "strings" + "sync" "unicode" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/lipgloss" ) @@ -32,6 +36,27 @@ type token struct { end int } +type syntaxClass int + +const ( + syntaxClassNone syntaxClass = -1 + + syntaxClassKeyword syntaxClass = iota + syntaxClassString + syntaxClassComment + syntaxClassType + syntaxClassFunction + syntaxClassNumber + syntaxClassOperator + syntaxClassPreprocessor +) + +type syntaxRange struct { + start int + end int + class syntaxClass +} + var ( addBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("78")) deleteBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) @@ -49,6 +74,9 @@ var ( cursorCommentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("201")).Bold(true) commentInlineTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("236")) + + syntaxLexerCacheMu sync.RWMutex + syntaxLexerCache = make(map[string]chroma.Lexer) ) func RenderSplit( @@ -226,15 +254,16 @@ func renderRowSegments( baseStyle = baseStyle.Background(cursorRowBg) } changed := highlightRanges(row, side) + syntax := syntaxRangesForPath(row.Path, plainText) out := make([]string, 0, len(chunks)) - firstStyled := styleChunk(chunks[0].text, chunks[0].start, changed, baseStyle, highlightStyle) + firstStyled := styleChunk(chunks[0].text, chunks[0].start, changed, syntax, baseStyle, highlightStyle) metaStyled := styleMeta(meta, row.Kind, side, isCursor) out = append(out, prefix+metaStyled+firstStyled+strings.Repeat(" ", textWidth-len([]rune(chunks[0].text)))) contMeta := strings.Repeat(" ", metaWidth) for _, chunk := range chunks[1:] { - styled := styleChunk(chunk.text, chunk.start, changed, baseStyle, highlightStyle) + styled := styleChunk(chunk.text, chunk.start, changed, syntax, baseStyle, highlightStyle) out = append(out, contPrefix+contMeta+styled+strings.Repeat(" ", textWidth-len([]rune(chunk.text)))) } @@ -302,35 +331,177 @@ func highlightRanges(row DiffRow, side Side) []textRange { return newRanges } -func styleChunk(text string, chunkStart int, ranges []textRange, baseStyle, highlightStyle lipgloss.Style) string { +func styleChunk(text string, chunkStart int, ranges []textRange, syntax []syntaxRange, baseStyle, highlightStyle lipgloss.Style) string { if text == "" { return "" } - if len(ranges) == 0 { + if len(ranges) == 0 && len(syntax) == 0 { return baseStyle.Render(text) } runes := []rune(text) var b strings.Builder + rangeIdx := 0 + syntaxIdx := 0 start := 0 - inHighlight := inRanges(chunkStart, ranges) + stateDiff, stateSyntax := styleStateAt(chunkStart, ranges, syntax, &rangeIdx, &syntaxIdx) for i := 1; i <= len(runes); i++ { - if i == len(runes) || inRanges(chunkStart+i, ranges) != inHighlight { + nextDiff, nextSyntax := stateDiff, stateSyntax + if i < len(runes) { + nextDiff, nextSyntax = styleStateAt(chunkStart+i, ranges, syntax, &rangeIdx, &syntaxIdx) + } + if i == len(runes) || nextDiff != stateDiff || nextSyntax != stateSyntax { seg := string(runes[start:i]) - if inHighlight { + switch { + case stateDiff: b.WriteString(highlightStyle.Render(seg)) - } else { + case stateSyntax != syntaxClassNone: + b.WriteString(applySyntaxClass(baseStyle, stateSyntax).Render(seg)) + default: b.WriteString(baseStyle.Render(seg)) } start = i - if i < len(runes) { - inHighlight = !inHighlight - } + stateDiff = nextDiff + stateSyntax = nextSyntax } } return b.String() } +func styleStateAt(pos int, ranges []textRange, syntax []syntaxRange, rangeIdx, syntaxIdx *int) (bool, syntaxClass) { + for *rangeIdx < len(ranges) && pos >= ranges[*rangeIdx].end { + *rangeIdx = *rangeIdx + 1 + } + for *syntaxIdx < len(syntax) && pos >= syntax[*syntaxIdx].end { + *syntaxIdx = *syntaxIdx + 1 + } + + inDiff := *rangeIdx < len(ranges) && pos >= ranges[*rangeIdx].start + if *syntaxIdx < len(syntax) && pos >= syntax[*syntaxIdx].start { + return inDiff, syntax[*syntaxIdx].class + } + return inDiff, syntaxClassNone +} + +func applySyntaxClass(base lipgloss.Style, class syntaxClass) lipgloss.Style { + switch class { + case syntaxClassKeyword: + return base.Foreground(lipgloss.Color("141")) + case syntaxClassString: + return base.Foreground(lipgloss.Color("186")) + case syntaxClassComment: + return base.Foreground(lipgloss.Color("244")).Italic(true) + case syntaxClassType: + return base.Foreground(lipgloss.Color("117")) + case syntaxClassFunction: + return base.Foreground(lipgloss.Color("221")) + case syntaxClassNumber: + return base.Foreground(lipgloss.Color("215")) + case syntaxClassOperator: + return base.Foreground(lipgloss.Color("204")) + case syntaxClassPreprocessor: + return base.Foreground(lipgloss.Color("178")) + default: + return base + } +} + +func syntaxRangesForPath(path, text string) []syntaxRange { + if text == "" { + return nil + } + lexer := syntaxLexerForPath(path) + if lexer == nil { + return nil + } + it, err := lexer.Tokenise(nil, text) + if err != nil { + return nil + } + + ranges := make([]syntaxRange, 0, 16) + pos := 0 + for tok := it(); tok != chroma.EOF; tok = it() { + length := len([]rune(tok.Value)) + if length == 0 { + continue + } + if class, ok := syntaxClassForToken(tok.Type); ok { + ranges = append(ranges, syntaxRange{start: pos, end: pos + length, class: class}) + } + pos += length + } + return mergeSyntaxRanges(ranges) +} + +func syntaxLexerForPath(path string) chroma.Lexer { + name := strings.ToLower(filepath.Ext(path)) + if name == "" { + return nil + } + + syntaxLexerCacheMu.RLock() + if lx, ok := syntaxLexerCache[name]; ok { + syntaxLexerCacheMu.RUnlock() + return lx + } + syntaxLexerCacheMu.RUnlock() + + lx := lexers.Match("x" + name) + if lx == nil { + return nil + } + lx = chroma.Coalesce(lx) + + syntaxLexerCacheMu.Lock() + syntaxLexerCache[name] = lx + syntaxLexerCacheMu.Unlock() + return lx +} + +func syntaxClassForToken(ttype chroma.TokenType) (syntaxClass, bool) { + switch { + case ttype.InCategory(chroma.Comment): + return syntaxClassComment, true + case ttype.InCategory(chroma.LiteralString): + return syntaxClassString, true + case ttype.InCategory(chroma.Keyword): + return syntaxClassKeyword, true + case ttype.InCategory(chroma.NameClass), ttype.InCategory(chroma.NameBuiltinPseudo): + return syntaxClassType, true + case ttype.InCategory(chroma.NameFunction), ttype.InCategory(chroma.NameFunctionMagic), ttype.InCategory(chroma.NameDecorator): + return syntaxClassFunction, true + case ttype.InCategory(chroma.LiteralNumber): + return syntaxClassNumber, true + case ttype.InCategory(chroma.Operator): + return syntaxClassOperator, true + case ttype == chroma.CommentPreproc || ttype == chroma.CommentPreprocFile || ttype == chroma.KeywordNamespace: + return syntaxClassPreprocessor, true + default: + return 0, false + } +} + +func mergeSyntaxRanges(in []syntaxRange) []syntaxRange { + if len(in) == 0 { + return nil + } + out := make([]syntaxRange, 0, len(in)) + cur := in[0] + for _, r := range in[1:] { + if r.start <= cur.end && r.class == cur.class { + if r.end > cur.end { + cur.end = r.end + } + continue + } + out = append(out, cur) + cur = r + } + out = append(out, cur) + return out +} + func inRanges(pos int, ranges []textRange) bool { for _, r := range ranges { if pos >= r.start && pos < r.end { diff --git a/internal/diffview/render_test.go b/internal/diffview/render_test.go index 4118a70..57acbbb 100644 --- a/internal/diffview/render_test.go +++ b/internal/diffview/render_test.go @@ -180,6 +180,41 @@ func TestRenderSplitWithLayoutHighlightsChangedWords(t *testing.T) { } } +func TestSyntaxRangesForPathUsesChromaLexerByExtension(t *testing.T) { + rangesGo := syntaxRangesForPath("example.go", "if n > 10 { return \"x\" }") + if len(rangesGo) == 0 { + t.Fatalf("expected syntax ranges for Go file") + } + rangesTxt := syntaxRangesForPath("example.txt", "if n > 10 { return \"x\" }") + if len(rangesTxt) != 0 { + t.Fatalf("expected no syntax ranges for text file, got %v", rangesTxt) + } +} + +func TestSyntaxRangesForPathClassifiesKeywordAndString(t *testing.T) { + ranges := syntaxRangesForPath("example.go", "if n == 1 { return \"x\" }") + if len(ranges) == 0 { + t.Fatalf("expected syntax ranges for Go sample") + } + + hasKeyword := false + hasString := false + for _, r := range ranges { + if r.class == syntaxClassKeyword { + hasKeyword = true + } + if r.class == syntaxClassString { + hasString = true + } + } + if !hasKeyword { + t.Fatalf("expected at least one keyword syntax range, got %v", ranges) + } + if !hasString { + t.Fatalf("expected at least one string syntax range, got %v", ranges) + } +} + func TestRenderSplitShowsCommentMarkerOnBothPanes(t *testing.T) { rows := []DiffRow{ {Kind: RowChange, Path: "a.txt", OldLine: intPtr(4), NewLine: intPtr(4), OldText: "old", NewText: "new"}, From 463915ee4e8b8b57a1822f592a4e5f1cf31e566c Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Wed, 25 Feb 2026 10:20:48 +0100 Subject: [PATCH 2/3] improve background highlighting --- internal/diffview/render.go | 104 ++++++++++++++++++++++++++----- internal/diffview/render_test.go | 34 ++++++++++ 2 files changed, 121 insertions(+), 17 deletions(-) diff --git a/internal/diffview/render.go b/internal/diffview/render.go index 973d5ab..b2fed27 100644 --- a/internal/diffview/render.go +++ b/internal/diffview/render.go @@ -58,10 +58,10 @@ type syntaxRange struct { } var ( - addBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("78")) - deleteBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - changeOldBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")) - changeNewBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")) + addBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("78")).Background(lipgloss.Color("#1a2620")) + deleteBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Background(lipgloss.Color("#2a1f21")) + changeOldBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("#252022")) + changeNewBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("#1f2523")) contextBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) hunkBaseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("111")).Bold(true) @@ -72,6 +72,14 @@ var ( cursorGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("45")).Bold(true) commentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("220")).Bold(true) cursorCommentGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("16")).Background(lipgloss.Color("201")).Bold(true) + addGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("22")).Bold(true) + deleteGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("52")).Bold(true) + changeOldGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("53")).Bold(true) + changeNewGutterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("23")).Bold(true) + addMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("22")).Bold(true) + deleteMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("52")).Bold(true) + changeOldMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("210")).Background(lipgloss.Color("53")).Bold(true) + changeNewMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Background(lipgloss.Color("23")).Bold(true) commentInlineTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("236")) @@ -195,8 +203,8 @@ func renderRowSegments( hasComment func(path string, line int, side Side) bool, ) []string { hasAnyComment := hasCommentOnAnySide(row, hasComment) - prefix := renderGutterPrefix(isCursor, hasAnyComment) - contPrefix := " " + prefix := renderGutterPrefix(isCursor, hasAnyComment, row.Kind, side) + contPrefix := renderContinuationGutterPrefix(isCursor, hasAnyComment, row.Kind, side) lineWidth := maxInt(1, width-lipgloss.Width(prefix)) switch row.Kind { @@ -218,7 +226,7 @@ func renderRowSegments( hstyle = hstyle.Background(cursorRowBg) } styled := hstyle.Render(chunk.text) - out = append(out, p+styled+strings.Repeat(" ", lineWidth-len([]rune(chunk.text)))) + out = append(out, p+styled+styledPad(hstyle, lineWidth-len([]rune(chunk.text)))) } if len(out) == 0 { out = append(out, prefix+strings.Repeat(" ", lineWidth)) @@ -259,18 +267,18 @@ func renderRowSegments( out := make([]string, 0, len(chunks)) firstStyled := styleChunk(chunks[0].text, chunks[0].start, changed, syntax, baseStyle, highlightStyle) metaStyled := styleMeta(meta, row.Kind, side, isCursor) - out = append(out, prefix+metaStyled+firstStyled+strings.Repeat(" ", textWidth-len([]rune(chunks[0].text)))) + out = append(out, prefix+metaStyled+firstStyled+styledPad(baseStyle, textWidth-len([]rune(chunks[0].text)))) - contMeta := strings.Repeat(" ", metaWidth) + contMeta := styleMeta(strings.Repeat(" ", metaWidth), row.Kind, side, isCursor) for _, chunk := range chunks[1:] { styled := styleChunk(chunk.text, chunk.start, changed, syntax, baseStyle, highlightStyle) - out = append(out, contPrefix+contMeta+styled+strings.Repeat(" ", textWidth-len([]rune(chunk.text)))) + out = append(out, contPrefix+contMeta+styled+styledPad(baseStyle, textWidth-len([]rune(chunk.text)))) } return out } -func renderGutterPrefix(isCursor, hasComment bool) string { +func renderGutterPrefix(isCursor, hasComment bool, kind RowKind, side Side) string { cursorMark := " " if isCursor { cursorMark = "▸" @@ -283,25 +291,87 @@ func renderGutterPrefix(isCursor, hasComment bool) string { switch { case isCursor && hasComment: - return cursorCommentGutterStyle.Render(marks) + " " + return cursorCommentGutterStyle.Render(marks + " ") case isCursor: - return cursorGutterStyle.Render(marks) + " " + return cursorGutterStyle.Render(marks + " ") case hasComment: - return commentGutterStyle.Render(marks) + " " + return commentGutterStyle.Render(marks + " ") default: + if style, ok := gutterStyleFor(kind, side); ok { + return style.Render(marks + " ") + } return marks + " " } } +func renderContinuationGutterPrefix(isCursor, hasComment bool, kind RowKind, side Side) string { + spaces := " " + switch { + case isCursor && hasComment: + return cursorCommentGutterStyle.Render(spaces) + case isCursor: + return cursorGutterStyle.Render(spaces) + case hasComment: + return commentGutterStyle.Render(spaces) + default: + if style, ok := gutterStyleFor(kind, side); ok { + return style.Render(spaces) + } + return spaces + } +} + func styleMeta(meta string, kind RowKind, side Side, isCursor bool) string { - base, _ := stylesForContent(kind, side) - metaStyle := base.Bold(isCursor) if isCursor { - metaStyle = metaStyle.Foreground(lipgloss.Color("230")).Background(cursorRowBg) + return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(cursorRowBg).Render(meta) + } + if style, ok := metaStyleFor(kind, side); ok { + return style.Render(meta) } + base, _ := stylesForContent(kind, side) + metaStyle := base return metaStyle.Render(meta) } +func gutterStyleFor(kind RowKind, side Side) (lipgloss.Style, bool) { + switch kind { + case RowAdd: + return addGutterStyle, true + case RowDelete: + return deleteGutterStyle, true + case RowChange: + if side == SideOld { + return changeOldGutterStyle, true + } + return changeNewGutterStyle, true + default: + return lipgloss.Style{}, false + } +} + +func metaStyleFor(kind RowKind, side Side) (lipgloss.Style, bool) { + switch kind { + case RowAdd: + return addMetaStyle, true + case RowDelete: + return deleteMetaStyle, true + case RowChange: + if side == SideOld { + return changeOldMetaStyle, true + } + return changeNewMetaStyle, true + default: + return lipgloss.Style{}, false + } +} + +func styledPad(style lipgloss.Style, width int) string { + if width <= 0 { + return "" + } + return style.Render(strings.Repeat(" ", width)) +} + func stylesForContent(kind RowKind, side Side) (lipgloss.Style, lipgloss.Style) { switch kind { case RowAdd: diff --git a/internal/diffview/render_test.go b/internal/diffview/render_test.go index 57acbbb..dce79bd 100644 --- a/internal/diffview/render_test.go +++ b/internal/diffview/render_test.go @@ -180,6 +180,40 @@ func TestRenderSplitWithLayoutHighlightsChangedWords(t *testing.T) { } } +func TestDiffAccentStyleSelection(t *testing.T) { + if _, ok := gutterStyleFor(RowAdd, SideNew); !ok { + t.Fatalf("expected add gutter style") + } + if _, ok := gutterStyleFor(RowDelete, SideOld); !ok { + t.Fatalf("expected delete gutter style") + } + if _, ok := gutterStyleFor(RowChange, SideOld); !ok { + t.Fatalf("expected old change gutter style") + } + if _, ok := gutterStyleFor(RowChange, SideNew); !ok { + t.Fatalf("expected new change gutter style") + } + if _, ok := gutterStyleFor(RowContext, SideOld); ok { + t.Fatalf("expected no context gutter style") + } + + if _, ok := metaStyleFor(RowAdd, SideNew); !ok { + t.Fatalf("expected add meta style") + } + if _, ok := metaStyleFor(RowDelete, SideOld); !ok { + t.Fatalf("expected delete meta style") + } + if _, ok := metaStyleFor(RowChange, SideOld); !ok { + t.Fatalf("expected old change meta style") + } + if _, ok := metaStyleFor(RowChange, SideNew); !ok { + t.Fatalf("expected new change meta style") + } + if _, ok := metaStyleFor(RowContext, SideOld); ok { + t.Fatalf("expected no context meta style") + } +} + func TestSyntaxRangesForPathUsesChromaLexerByExtension(t *testing.T) { rangesGo := syntaxRangesForPath("example.go", "if n > 10 { return \"x\" }") if len(rangesGo) == 0 { From 575653327a50a5a47219174b553ddf35de7f1739 Mon Sep 17 00:00:00 2001 From: Michael Rose Date: Wed, 25 Feb 2026 10:27:12 +0100 Subject: [PATCH 3/3] add action --- .github/workflows/go-ci.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/go-ci.yml diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000..9491c0a --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,38 @@ +name: Go CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Lint + run: make lint + + - name: Test + run: go test ./... + + - name: Build + run: go build ./...