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
1 change: 1 addition & 0 deletions .claude-plugin/skills/revdiff/references/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Then uncomment and edit the values you want to change.
| `--line-numbers` | `REVDIFF_LINE_NUMBERS` | Show line numbers in diff gutter | `false` |
| `--blame` | `REVDIFF_BLAME` | Show blame gutter | `false` |
| `--word-diff` | `REVDIFF_WORD_DIFF` | Highlight intra-line word-level changes in paired add/remove lines | `false` |
| `--annotation-marker` | `REVDIFF_ANNOTATION_MARKER` | Prefix shown before annotation lines | `💬` |
| `--no-confirm-discard` | `REVDIFF_NO_CONFIRM_DISCARD` | Skip confirmation when discarding annotations with Q | `false` |
| `--no-mouse` | `REVDIFF_NO_MOUSE` | Disable mouse support (scroll wheel, click) | `false` |
| `--vim-motion` | `REVDIFF_VIM_MOTION` | Enable vim-style motion preset (counts, `gg`, `G`, `zz`/`zt`/`zb`, `ZZ`/`ZQ`) | `false` |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ Positional arguments support several forms:
| `--line-numbers` | Show line numbers in diff gutter, env: `REVDIFF_LINE_NUMBERS` | `false` |
| `--blame` | Show blame gutter, env: `REVDIFF_BLAME` | `false` |
| `--word-diff` | Highlight intra-line word-level changes in paired add/remove lines, env: `REVDIFF_WORD_DIFF` | `false` |
| `--annotation-marker` | Prefix shown before annotation lines, env: `REVDIFF_ANNOTATION_MARKER` | `💬` |
| `--no-confirm-discard` | Skip confirmation when discarding annotations with Q, env: `REVDIFF_NO_CONFIRM_DISCARD` | `false` |
| `--no-mouse` | Disable mouse support (scroll wheel, click), env: `REVDIFF_NO_MOUSE` | `false` |
| `--vim-motion` | Enable vim-style motion preset (counts, `gg`, `G`, `zz`/`zt`/`zb`, `ZZ`/`ZQ`), env: `REVDIFF_VIM_MOTION` | `false` |
Expand Down
5 changes: 5 additions & 0 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type options struct {
LineNumbers bool `long:"line-numbers" ini-name:"line-numbers" env:"REVDIFF_LINE_NUMBERS" description:"show line numbers in diff gutter"`
Blame bool `long:"blame" ini-name:"blame" env:"REVDIFF_BLAME" description:"show blame gutter"`
WordDiff bool `long:"word-diff" ini-name:"word-diff" env:"REVDIFF_WORD_DIFF" description:"highlight intra-line word-level changes in paired add/remove lines"`
AnnotationMarker string `long:"annotation-marker" ini-name:"annotation-marker" env:"REVDIFF_ANNOTATION_MARKER" default:"💬" description:"prefix shown before annotation lines"`
VimMotion bool `long:"vim-motion" ini-name:"vim-motion" env:"REVDIFF_VIM_MOTION" description:"enable vim-style motion preset (counts, gg, G, zz/zt/zb, ZZ/ZQ)"`
ChromaStyle string `long:"chroma-style" ini-name:"chroma-style" env:"REVDIFF_CHROMA_STYLE" default:"catppuccin-macchiato" description:"chroma style for syntax highlighting"`
AllFiles bool `long:"all-files" short:"A" no-ini:"true" description:"browse all tracked files, not just diffs (git and jj only)"`
Expand Down Expand Up @@ -156,6 +157,10 @@ func parseArgs(args []string) (options, error) {
return options{}, errors.New("--compact-context must be >= 1")
}

if strings.ContainsAny(opts.AnnotationMarker, "\n\r\t") {
return options{}, errors.New("--annotation-marker cannot contain newlines or tabs")
}

if opts.Description != "" && opts.DescriptionFile != "" {
return options{}, errors.New("--description and --description-file are mutually exclusive")
}
Expand Down
60 changes: 60 additions & 0 deletions app/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func TestParseArgs_Defaults(t *testing.T) {
assert.Equal(t, 2, opts.TreeWidth)
assert.Equal(t, 4, opts.TabWidth)
assert.Equal(t, "catppuccin-macchiato", opts.ChromaStyle)
assert.Equal(t, "💬", opts.AnnotationMarker)
assert.False(t, opts.Staged)
assert.False(t, opts.NoColors)
assert.False(t, opts.NoStatusBar)
Expand Down Expand Up @@ -413,6 +414,65 @@ func TestParseArgs_WordDiff(t *testing.T) {
})
}

func TestParseArgs_AnnotationMarker(t *testing.T) {
t.Run("flag", func(t *testing.T) {
opts, err := parseArgs(append(noConfigArgs(t), "--annotation-marker=▸"))
require.NoError(t, err)
assert.Equal(t, "▸", opts.AnnotationMarker)
})

t.Run("empty flag", func(t *testing.T) {
opts, err := parseArgs(append(noConfigArgs(t), "--annotation-marker="))
require.NoError(t, err)
assert.Empty(t, opts.AnnotationMarker)
})

t.Run("env", func(t *testing.T) {
t.Setenv("REVDIFF_ANNOTATION_MARKER", ">>>")
opts, err := parseArgs(noConfigArgs(t))
require.NoError(t, err)
assert.Equal(t, ">>>", opts.AnnotationMarker)
})

t.Run("config file", func(t *testing.T) {
cfgDir := t.TempDir()
cfgPath := filepath.Join(cfgDir, "config")
err := os.WriteFile(cfgPath, []byte("[Application Options]\nannotation-marker = #\n"), 0o600)
require.NoError(t, err)
opts, err := parseArgs([]string{"--config", cfgPath})
require.NoError(t, err)
assert.Equal(t, "#", opts.AnnotationMarker)
})

t.Run("config file empty", func(t *testing.T) {
cfgDir := t.TempDir()
cfgPath := filepath.Join(cfgDir, "config")
err := os.WriteFile(cfgPath, []byte("[Application Options]\nannotation-marker =\n"), 0o600)
require.NoError(t, err)
opts, err := parseArgs([]string{"--config", cfgPath})
require.NoError(t, err)
assert.Empty(t, opts.AnnotationMarker)
})

t.Run("rejects newline", func(t *testing.T) {
_, err := parseArgs(append(noConfigArgs(t), "--annotation-marker=a\nb"))
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot contain newlines or tabs")
})

t.Run("rejects tab", func(t *testing.T) {
_, err := parseArgs(append(noConfigArgs(t), "--annotation-marker=a\tb"))
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot contain newlines or tabs")
})

t.Run("rejects carriage return", func(t *testing.T) {
_, err := parseArgs(append(noConfigArgs(t), "--annotation-marker=a\rb"))
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot contain newlines or tabs")
})
}

func TestParseArgs_VimMotion(t *testing.T) {
t.Run("default", func(t *testing.T) {
opts, err := parseArgs(noConfigArgs(t))
Expand Down
15 changes: 8 additions & 7 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,14 @@ func run(opts options) error {
vcsType: vcsType,
description: description,
}),
TabWidth: opts.TabWidth,
Ref: opts.ref(),
Staged: opts.Staged,
TreeWidthRatio: opts.TreeWidth,
Only: opts.Only,
WorkDir: workDir,
ActiveThemeName: themes.catalog.ActiveName(opts.Theme),
TabWidth: opts.TabWidth,
Ref: opts.ref(),
Staged: opts.Staged,
TreeWidthRatio: opts.TreeWidth,
Only: opts.Only,
WorkDir: workDir,
ActiveThemeName: themes.catalog.ActiveName(opts.Theme),
AnnotationMarker: opts.AnnotationMarker,
NewFileTree: func(entries []diff.FileEntry) ui.FileTreeComponent {
return sidepane.NewFileTree(entries)
},
Expand Down
24 changes: 17 additions & 7 deletions app/ui/annotate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ import (
// annotKeyFile is the lookup key for file-level annotations in wrappedAnnotationLineCount.
const annotKeyFile = "file"

// annotPrefix returns the cached annotation line prefix (marker + space).
func (m Model) annotPrefix() string {
return m.cfg.annotPrefix
}

// annotFilePrefix returns the cached file-level annotation prefix (marker + " file: ").
func (m Model) annotFilePrefix() string {
return m.cfg.annotFilePrefix
}

// annotCharLimit caps annotation text length. sized for multi-item lists and
// small pasted data slices, not for full-document content.
const annotCharLimit = 8000
Expand Down Expand Up @@ -87,7 +97,7 @@ func (m *Model) startAnnotation() tea.Cmd {
}
}

ti, cmd := m.newAnnotationInput(placeholder, 6) // cursor col + emoji prefix "💬 " + border margin
ti, cmd := m.newAnnotationInput(placeholder, 3+lipgloss.Width(m.annotPrefix())) // cursor col + annotation prefix + border margin
if preFill != "" {
ti.SetValue(preFill)
}
Expand Down Expand Up @@ -147,7 +157,7 @@ func (m *Model) startFileAnnotation() tea.Cmd {
}
}

ti, cmd := m.newAnnotationInput(placeholder, 12) // cursor col + "💬 file: " prefix + border margin
ti, cmd := m.newAnnotationInput(placeholder, 3+lipgloss.Width(m.annotFilePrefix())) // cursor col + file annotation prefix + border margin
if preFill != "" {
ti.SetValue(preFill)
}
Expand Down Expand Up @@ -391,15 +401,15 @@ type annotCacheKey struct {

// annotationPrefixBody resolves the (prefix, body) pair for the annotation
// identified by key. file-level annotations (key == annotKeyFile) get the
// "💬 file: " prefix; line-level annotations get "💬 ". returns ("", "") when
// no annotation matches the key.
// file-level prefix; line-level annotations get the line prefix. returns ("", "")
// when no annotation matches the key.
func (m Model) annotationPrefixBody(key string) (prefix, body string) {
for _, a := range m.store.Get(m.file.name) {
if key == annotKeyFile && a.Line == 0 {
return "\U0001f4ac file: ", a.Comment
return m.annotFilePrefix(), a.Comment
}
if key != annotKeyFile && m.annotationKey(a.Line, a.Type) == key {
return "\U0001f4ac ", a.Comment
return m.annotPrefix(), a.Comment
}
}
return "", ""
Expand Down Expand Up @@ -439,7 +449,7 @@ func (m *Model) annotationVisualRows(prefix, body string) []string {
func (m Model) composeAnnotationRows(prefix, body string, wrapW int) []string {
first := prefix + body
logical := strings.Split(first, "\n")
indent := m.annotationContinuationIndent(logical[0])
indent := strings.Repeat(" ", lipgloss.Width(prefix))

var rows []string
for i, segment := range logical {
Expand Down
121 changes: 120 additions & 1 deletion app/ui/annotate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/umputun/revdiff/app/ui/overlay"
"github.com/umputun/revdiff/app/ui/sidepane"
"github.com/umputun/revdiff/app/ui/style"
"github.com/umputun/revdiff/app/ui/worddiff"
)

func TestModel_AnnotatedFilesMarker(t *testing.T) {
Expand Down Expand Up @@ -501,6 +502,84 @@ func TestModel_RenderDiffWithAnnotations(t *testing.T) {
assert.Contains(t, rendered, "\U0001f4ac")
}

func TestModel_CustomAnnotationMarker(t *testing.T) {
res := style.PlainResolver()
m, err := NewModel(ModelConfig{
Renderer: plainRenderer(),
Store: annotation.NewStore(),
Highlighter: noopHighlighter(),
StyleResolver: res,
StyleRenderer: style.NewRenderer(res),
SGR: style.SGR{},
WordDiffer: worddiff.New(),
Overlay: overlay.NewManager(),
Themes: fakeThemeCatalog{},
TreeWidthRatio: 3,
AnnotationMarker: "▸",
NewFileTree: testFileTreeFactory(),
ParseTOC: testParseTOCFactory(),
})
require.NoError(t, err)
m.layout.width = 120
m.layout.height = 40
m.layout.treeWidth = m.layout.width * m.cfg.treeWidthRatio / 10
m.ready = true
m.filesLoaded = true

m.file.name = "a.go"
m.file.lines = []diff.DiffLine{
{NewNum: 1, Content: "package main", ChangeType: diff.ChangeContext},
{NewNum: 2, Content: "func foo() {}", ChangeType: diff.ChangeAdd},
}
m.store.Add(annotation.Annotation{File: "a.go", Line: 2, Type: "+", Comment: "note"})
m.store.Add(annotation.Annotation{File: "a.go", Line: 0, Type: "", Comment: "file note"})

rendered := m.renderDiff()
assert.Contains(t, rendered, "▸ note", "line annotation should use custom marker")
assert.Contains(t, rendered, "▸ file: file note", "file annotation should use custom marker")
assert.NotContains(t, rendered, "\U0001f4ac", "default emoji should not appear with custom marker")
}

func TestModel_EmptyAnnotationMarkerExplicit(t *testing.T) {
res := style.PlainResolver()
m, err := NewModel(ModelConfig{
Renderer: plainRenderer(),
Store: annotation.NewStore(),
Highlighter: noopHighlighter(),
StyleResolver: res,
StyleRenderer: style.NewRenderer(res),
SGR: style.SGR{},
WordDiffer: worddiff.New(),
Overlay: overlay.NewManager(),
Themes: fakeThemeCatalog{},
TreeWidthRatio: 3,
AnnotationMarker: "",
NewFileTree: testFileTreeFactory(),
ParseTOC: testParseTOCFactory(),
})
require.NoError(t, err)
m.layout.width = 120
m.layout.height = 40
m.layout.treeWidth = m.layout.width * m.cfg.treeWidthRatio / 10
m.ready = true
m.filesLoaded = true

m.file.name = "a.go"
m.file.lines = []diff.DiffLine{
{NewNum: 1, Content: "line1", ChangeType: diff.ChangeContext},
}
m.store.Add(annotation.Annotation{File: "a.go", Line: 1, Type: " ", Comment: "bare note"})
m.store.Add(annotation.Annotation{File: "a.go", Line: 0, Type: "", Comment: "file note"})

rendered := m.renderDiff()
// verify empty marker produces bare space prefix, not emoji fallback
assert.NotContains(t, rendered, "\U0001f4ac", "empty marker should not produce emoji")
assert.NotContains(t, rendered, "\U0001f4ac bare note", "should not have emoji before line annotation")
assert.NotContains(t, rendered, "\U0001f4ac file:", "should not have emoji before file annotation")
assert.Contains(t, rendered, " bare note", "empty marker should render bare prefix for line annotation")
assert.Contains(t, rendered, " file: file note", "empty marker should render ' file: ' prefix for file annotation")
}

func TestModel_RenderDiffAnnotationInput(t *testing.T) {
m := testModel(nil, nil)
m.file.name = "a.go"
Expand Down Expand Up @@ -1058,7 +1137,47 @@ func TestModel_FileAnnotationInputWidthNarrowerThanLineLevel(t *testing.T) {
fileWidth := m.annot.input.Width

assert.Greater(t, lineWidth, fileWidth, "file-level input should be narrower than line-level due to wider prefix")
assert.Equal(t, 6, lineWidth-fileWidth, "width difference should match prefix width difference (12-6=6)")
assert.Equal(t, 6, lineWidth-fileWidth, "width difference should match prefix width difference")
}

func TestModel_AnnotationInputWidthUsesMarkerWidth(t *testing.T) {
lines := []diff.DiffLine{{NewNum: 1, Content: "line1", ChangeType: diff.ChangeAdd}}
tests := []struct {
name string
marker string
wantLineWidth int
wantFileWidth int
}{
{name: "default emoji marker", marker: "\U0001f4ac", wantLineWidth: 78, wantFileWidth: 72},
{name: "wide emoji marker", marker: "\U0001f4ac\U0001f4ac", wantLineWidth: 76, wantFileWidth: 70},
{name: "empty marker", marker: "", wantLineWidth: 80, wantFileWidth: 74},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := testModel([]string{"a.go"}, nil)
m.tree = testNewFileTree([]string{"a.go"})
m.file.name = "a.go"
m.file.lines = lines
m.nav.diffCursor = 0
m.layout.focus = paneDiff
m.layout.width = 120
m.layout.treeWidth = 30
m.layout.treeHidden = false
m.cfg.annotationMarker = tt.marker
m.cfg.annotPrefix = tt.marker + " "
m.cfg.annotFilePrefix = tt.marker + " file: "

require.Equal(t, 84, m.diffContentWidth(), "test fixture pins absolute width")

m.startAnnotation()
assert.Equal(t, tt.wantLineWidth, m.annot.input.Width, "line annotation input width")

m.annot.annotating = false
m.startFileAnnotation()
assert.Equal(t, tt.wantFileWidth, m.annot.input.Width, "file annotation input width")
})
}
}

func TestModel_FileAnnotationSavesWithLineZero(t *testing.T) {
Expand Down
22 changes: 4 additions & 18 deletions app/ui/diffview.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ func (m Model) buildAnnotationMap() (annotations map[string]string, fileComment
func (m Model) renderFileAnnotationHeader(b *strings.Builder, fileComment string) {
// when actively editing a file-level annotation, always show the input widget
if m.annot.annotating && m.annot.fileAnnotating {
line := " " + m.renderer.AnnotationInline("\U0001f4ac file: ") + m.annot.input.View()
line := " " + m.renderer.AnnotationInline(m.annotFilePrefix()) + m.annot.input.View()
// strip textinput's unstyled trailing padding so extendLineBg can re-pad with DiffBg
line = strings.TrimRight(line, " ")
b.WriteString(m.extendLineBg(line, m.resolver.Color(style.ColorKeyDiffPaneBg)) + "\n")
Expand All @@ -330,7 +330,7 @@ func (m Model) renderFileAnnotationHeader(b *strings.Builder, fileComment string
if m.nav.diffCursor == -1 && m.layout.focus == paneDiff {
cursor = m.renderer.DiffCursor(m.cfg.noColors)
}
m.renderWrappedAnnotation(b, cursor, "\U0001f4ac file: ", fileComment)
m.renderWrappedAnnotation(b, cursor, m.annotFilePrefix(), fileComment)
}
}

Expand Down Expand Up @@ -655,7 +655,7 @@ func (m Model) extendLineBg(styled string, bg style.Color) string {
// renderAnnotationOrInput writes the annotation input or existing annotation below a diff line.
func (m Model) renderAnnotationOrInput(b *strings.Builder, idx int, annotationMap map[string]string) {
if m.annot.annotating && !m.annot.fileAnnotating && idx == m.nav.diffCursor {
line := " " + m.renderer.AnnotationInline("\U0001f4ac ") + m.annot.input.View()
line := " " + m.renderer.AnnotationInline(m.annotPrefix()) + m.annot.input.View()
// strip textinput's unstyled trailing padding so extendLineBg can re-pad with DiffBg
line = strings.TrimRight(line, " ")
b.WriteString(m.extendLineBg(line, m.resolver.Color(style.ColorKeyDiffPaneBg)) + "\n")
Expand All @@ -669,7 +669,7 @@ func (m Model) renderAnnotationOrInput(b *strings.Builder, idx int, annotationMa
if idx == m.nav.diffCursor && m.annot.cursorOnAnnotation && m.layout.focus == paneDiff {
cursor = m.renderer.DiffCursor(m.cfg.noColors)
}
m.renderWrappedAnnotation(b, cursor, "\U0001f4ac ", comment)
m.renderWrappedAnnotation(b, cursor, m.annotPrefix(), comment)
}
}
}
Expand All @@ -695,20 +695,6 @@ func (m Model) renderWrappedAnnotation(b *strings.Builder, cursor, prefix, body
}
}

// annotationContinuationIndent returns leading whitespace sized to match the emoji
// prefix on the first logical line of an annotation so continuation logical lines
// align under the body. Uses lipgloss.Width because the emoji is double-width.
func (m Model) annotationContinuationIndent(firstLogicalLine string) string {
switch {
case strings.HasPrefix(firstLogicalLine, "\U0001f4ac file: "):
return strings.Repeat(" ", lipgloss.Width("\U0001f4ac file: "))
case strings.HasPrefix(firstLogicalLine, "\U0001f4ac "):
return strings.Repeat(" ", lipgloss.Width("\U0001f4ac "))
default:
return ""
}
}

const (
wrapGutterWidth = 3 // wrap gutter prefix width: " + ", " - ", " ", " ↪ "
wrapMinContent = 10 // minimum content width per visual row when wrap-indent is active
Expand Down
Loading