Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"os"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/jessevdk/go-flags"
"github.com/muesli/termenv"

"github.com/umputun/revdiff/app/annotation"
"github.com/umputun/revdiff/app/diff"
Expand Down Expand Up @@ -83,6 +85,17 @@ func main() {
}

func run(opts options) error {
// force lipgloss to truecolor when colors are enabled. revdiff's raw-ANSI
// helpers (style.ansiColor) always emit truecolor, but lipgloss respects
// the termenv-detected profile, which can downgrade to ANSI256 / ANSI in
// tmux or terminals where TERM/COLORTERM detection regresses. The mismatch
// makes lipgloss-rendered colors (pane borders, file tree fg) look wrong
// while raw-ANSI paths (line prefix wrap, overlay title injection) render
// correctly. Forcing truecolor unifies the two paths.
if !opts.NoColors {
lipgloss.SetColorProfile(termenv.TrueColor)
}

store := annotation.NewStore()
hl := highlight.New(opts.ChromaStyle, !opts.NoColors)
keysPath := opts.Keys
Expand Down
16 changes: 13 additions & 3 deletions app/ui/collapsed.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,17 @@ func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffL
numGutter, blGutter := m.lineGutters(dl)

bgColor := m.resolver.Color(style.ColorKeyAddLineBg)
prefixFg := m.resolver.LineFg(diff.ChangeAdd)
if modified {
bgColor = m.resolver.Color(style.ColorKeyModifyLineBg)
prefixFg = m.resolver.Color(style.ColorKeyModifyLineFg)
}

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In collapsed mode, when a line is a search match you switch to StyleKeySearchMatch and unset the foreground for the highlighted path, but the prefix fg is still taken from add/modify (LineFg(ChangeAdd) or ColorKeyModifyLineFg). This makes the gutter prefix color differ between highlighted vs non-highlighted search matches, and it also contradicts the intent that search matches render with search styling instead of add/modify styling. Consider deriving the prefix fg from the search-match style (e.g., keep the search fg for the prefix even when unsetting foreground for chroma content, or introduce a raw ColorKeySearchFg similar to ColorKeyNormalFg and use it here when isSearchMatch).

Suggested change
}
}
if isSearchMatch {
prefixFg = lineStyle.GetForeground()
}

Copilot uses AI. Check for mistakes.
if isSearchMatch {
// match the non-highlighted search-match path which uses the search-match
// lipgloss style (fg + bg). when chroma highlighting is on, lineHlStyle
// drops the foreground for chroma to own content fg, so the prefix wrap
// must inject search-fg explicitly.
prefixFg = m.resolver.Color(style.ColorKeySearchFg)
}

// wrap mode: break long lines at word boundaries with continuation markers
Expand All @@ -115,13 +124,14 @@ func (m Model) renderCollapsedAddLine(b *strings.Builder, idx int, dl diff.DiffL
gutter: gutter, numGutter: numGutter, blGutter: blGutter,
isCursor: isCursor, hasHighlight: hasHighlight,
lineStyle: lineStyle, hlStyle: lineHlStyle, bgColor: bgColor,
prefixFg: prefixFg,
})
return
}

content := lineStyle.Render(gutter + lineContent)
if hasHighlight {
content = lineHlStyle.Render(gutter + textContent)
content = lineHlStyle.Render(m.wrapPrefixForHighlight(gutter, prefixFg, true) + textContent)
}
content = m.applyHorizontalScroll(content, bgColor)
content = m.extendLineBg(content, bgColor)
Expand All @@ -139,7 +149,7 @@ type wrappedLineCtx struct {
gutter, numGutter, blGutter string
isCursor, hasHighlight bool
lineStyle, hlStyle lipgloss.Style
bgColor style.Color
bgColor, prefixFg style.Color
}

// renderWrappedCollapsedLine renders a collapsed add line with word wrapping, producing continuation lines with ↪ markers.
Expand All @@ -158,7 +168,7 @@ func (m Model) renderWrappedCollapsedLine(b *strings.Builder, textContent string

var styled string
if ctx.hasHighlight {
styled = ctx.hlStyle.Render(prefix + vl)
styled = ctx.hlStyle.Render(m.wrapPrefixForHighlight(prefix, ctx.prefixFg, true) + vl)
} else {
styled = ctx.lineStyle.Render(prefix + vl)
}
Expand Down
14 changes: 13 additions & 1 deletion app/ui/diffview.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,18 @@ func (m Model) linePrefix(changeType diff.ChangeType) string {
}
}

// wrapPrefixForHighlight wraps the +/-/~ prefix in explicit raw ANSI fg when
// chroma highlighting is on. Highlighted line styles intentionally set only
// background (chroma owns per-token fg for content), so the prefix would
// otherwise inherit the terminal default fg and may render invisibly on
// light theme backgrounds.
func (m Model) wrapPrefixForHighlight(prefix string, fg style.Color, hasHighlight bool) string {
if !hasHighlight || fg == "" {
return prefix
}
return string(fg) + prefix + string(style.ResetFg)
}

// highlightSearchMatches wraps each occurrence of the search term in the visible text
// with ANSI background color sequence (preserving syntax foreground within matches).
// works with both plain text and ANSI-coded content by stripping ANSI to find match positions.
Expand Down Expand Up @@ -549,7 +561,7 @@ func (m Model) styleDiffContent(changeType diff.ChangeType, prefix, content stri
if isSearchMatch && m.search.term != "" {
content = m.highlightSearchMatches(content, changeType)
}

prefix = m.wrapPrefixForHighlight(prefix, m.resolver.LineFg(changeType), hasHighlight)
return m.resolver.LineStyle(changeType, hasHighlight).Render(prefix + content)
}

Expand Down
44 changes: 44 additions & 0 deletions app/ui/mocks/style_resolver.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type styleResolver interface {
Color(k style.ColorKey) style.Color
Style(k style.StyleKey) lipgloss.Style
LineBg(change diff.ChangeType) style.Color
LineFg(change diff.ChangeType) style.Color
LineStyle(change diff.ChangeType, highlighted bool) lipgloss.Style
WordDiffBg(change diff.ChangeType) style.Color
IndicatorBg(change diff.ChangeType) style.Color
Expand Down
10 changes: 9 additions & 1 deletion app/ui/overlay/themeselect.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,15 @@ func (t *themeSelectOverlay) formatEntry(item ThemeItem, width int, selected boo
return styled
}

return " " + swatch + " " + name
// wrap the name in raw normal-fg ANSI: the swatch resets fg after rendering
// its own colored glyph, so name would otherwise inherit terminal default fg
// and render invisibly on a session whose default fg matches the theme's
// pane background (e.g. light kitty session on a light theme).
normal := string(resolver.Color(style.ColorKeyNormalFg))
if normal == "" {
return " " + swatch + " " + name
}
return " " + swatch + " " + normal + name + string(style.ResetFg)
}

func (t *themeSelectOverlay) maxVisible() int {
Expand Down
16 changes: 14 additions & 2 deletions app/ui/style/color_key_enum.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/ui/style/enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const (
colorKeySearchBg
colorKeyAddLineFg
colorKeyRemoveLineFg
colorKeyModifyLineFg
colorKeySearchFg
colorKeyNormalFg
colorKeySelectedFg
)
Expand Down
21 changes: 21 additions & 0 deletions app/ui/style/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func (r Resolver) Color(k ColorKey) Color {
return Color(ansiColor(r.colors.AddFg, 38))
case ColorKeyRemoveLineFg:
return Color(ansiColor(r.colors.RemoveFg, 38))
case ColorKeyModifyLineFg:
return Color(ansiColor(r.colors.ModifyFg, 38))
case ColorKeySearchFg:
return Color(ansiColor(r.colors.SearchFg, 38))
case ColorKeyNormalFg:
return Color(ansiColor(r.colors.Normal, 38))
case ColorKeySelectedFg:
Expand Down Expand Up @@ -100,6 +104,23 @@ func (r Resolver) LineBg(change diff.ChangeType) Color {
}
}

// LineFg returns the ANSI foreground escape sequence for a diff change type.
// Used to color the +/-/~ prefix when chroma highlighting is active and the
// highlighted line style intentionally omits foreground (chroma owns content fg).
// ChangeAdd → AddFg, ChangeRemove → RemoveFg, everything else → empty.
// Modify lines (collapsed mode) are synthesized in the UI layer and not a
// diff change type — call Color(ColorKeyModifyLineFg) directly for those.
func (r Resolver) LineFg(change diff.ChangeType) Color {
switch change {
case diff.ChangeAdd:
return Color(ansiColor(r.colors.AddFg, 38))
case diff.ChangeRemove:
return Color(ansiColor(r.colors.RemoveFg, 38))
default:
return ""
}
}

// LineStyle returns the lipgloss.Style for a diff line based on change type
// and whether syntax highlighting is active.
func (r Resolver) LineStyle(change diff.ChangeType, highlighted bool) lipgloss.Style {
Expand Down
27 changes: 27 additions & 0 deletions app/ui/style/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,33 @@ func TestResolver_LineBg(t *testing.T) {
}
}

func TestResolver_LineFg(t *testing.T) {
r := NewResolver(fullColorsForTesting)

tests := []struct {
name string
change diff.ChangeType
want bool // true if non-empty expected
}{
{"add", diff.ChangeAdd, true},
{"remove", diff.ChangeRemove, true},
{"context", diff.ChangeContext, false},
{"divider", diff.ChangeDivider, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := r.LineFg(tt.change)
if tt.want {
assert.NotEmpty(t, string(got), "expected non-empty LineFg for %s", tt.change)
assert.Contains(t, string(got), "\033[38;2;", "expected ANSI fg sequence")
} else {
assert.Empty(t, string(got), "expected empty LineFg for %s", tt.change)
}
})
}
}

func TestResolver_LineStyle(t *testing.T) {
r := NewResolver(fullColorsForTesting)

Expand Down
2 changes: 1 addition & 1 deletion themes/gallery/colorblind-light
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

chroma-style = modus-operandi
color-accent = #0031a9
color-border = #595959
color-border = #b0b0b0
color-normal = #000000
color-muted = #595959
color-selected-fg = #0f0f0f
Expand Down