Skip to content

Commit 24365bf

Browse files
committed
Add git blame gutter toggle (B key)
Show author name and relative commit age per line in a gutter column, toggled with the B key. Blame data loads asynchronously via git blame and is keyed by new-side line numbers (blank for removed lines/dividers). - Add diff/blame.go with FileBlame parser and RelativeAge formatter - Add Blamer interface in ui/, wired via ModelConfig from cmd/main.go - Add blameGutter rendering in diffview.go alongside lineNumGutter - Extract shared gutter helpers (lineGutters, gutterExtra, gutterBlanks, applyHorizontalScroll) to reduce duplication across render paths - Add ActionToggleBlame keymap action bound to B - Update status bar mode icons to show @ when blame is active - Update README, docs, site, CLAUDE.md, and plugin references
1 parent d3f79ca commit 24365bf

15 files changed

Lines changed: 657 additions & 102 deletions

File tree

.claude-plugin/skills/revdiff/references/usage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ When `--only` specifies a file that has no git changes (or when no git repo exis
9999
| `w` | Toggle word wrap (long lines wrap with `` continuation markers) |
100100
| `t` | Toggle tree/TOC pane visibility (gives diff full terminal width) |
101101
| `L` | Toggle line numbers (side-by-side old/new numbers in gutter) |
102+
| `B` | Toggle git blame gutter (author name + commit age per line) |
102103
| `.` | Expand/collapse individual hunk under cursor (collapsed mode only) |
103104
| `f` | Toggle filter: all files / annotated only |
104105
| `?` | Toggle help overlay showing all keybindings |

CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine
3838
lineNumGutter(dl) formats " OOO NNN" gutter via m.styles.LineNumber,
3939
prepended in renderDiffLine, renderWrappedDiffLine, renderCollapsedAddLine, renderDeletePlaceholder
4040
lineNumWidth recomputed per file in handleFileLoaded; lineNumGutterWidth() = 2*W+2
41+
when blame gutter is on (`B` toggle, orthogonal to above):
42+
blameGutter(dl) formats " author age" gutter via m.styles.LineNumber,
43+
prepended after lineNumGutter in renderDiffLine, renderWrappedDiffLine, renderCollapsedAddLine, renderDeletePlaceholder
44+
blame data loaded async via loadBlame() → blameLoadedMsg; keyed by NewNum (blank for removed lines/dividers)
45+
blameAuthorLen capped at 8; blameGutterWidth() = W+5; Blamer interface (optional, nil when git unavailable)
4146
when wrap mode is on (`w` toggle, orthogonal to above):
4247
wrapContent() splits long lines via ansi.Wrap,
4348
continuation lines get `↪` gutter marker, cursorViewportY() sums wrapped line counts
@@ -98,7 +103,7 @@ git diff → diff.ParseUnifiedDiff() → []DiffLine
98103
- Help overlay uses `overlayCenter()` (ANSI-aware compositing via `charmbracelet/x/ansi.Cut`) to render on top of existing content; background (tree pane) remains visible at the edges
99104
- **ANSI nesting with lipgloss**: `lipgloss.Render()` emits `\033[0m` (full reset) which breaks outer style backgrounds. For styled substrings inside a lipgloss container (status bar separators, search highlights), use raw ANSI sequences via `ansiColor(hex, code)` — code 38 for fg, 48 for bg. Never use `lipgloss.NewStyle().Render()` for inline elements within a lipgloss-rendered parent.
100105
- **Background fill for themed panes**: lipgloss pane `Render()` and viewport internal padding emit plain spaces after `\033[0m` reset, causing pane background to show terminal default. Three workarounds: (1) `extendLineBg()` pads individual add/remove/modify lines to full content width with their specific bg color; (2) `padContentBg()` strips viewport trailing spaces and re-pads every line of pane content with DiffBg/TreeBg; (3) `BorderBackground()` is set on pane border styles to match pane bg. Context and line-number styles also set DiffBg explicitly via `contextStyle()`/`lineNumberStyle()`/`contextHighlightStyle()`.
101-
- Status bar mode icons (`▼ ◉ ↩ ≋ ⊟ #`) are always rendered on the right side via `statusModeIcons()`. `` indicates tree/TOC pane hidden via `t` toggle. Active modes use `StatusFg`, inactive use `Muted` — both via raw ANSI fg sequences. Graceful degradation on narrow terminals drops left segments: search position first (`statusSegmentsNoSearch`), then line number and hunk info (`statusSegmentsMinimal`), then truncates filename.
106+
- Status bar mode icons (`▼ ◉ ↩ ≋ ⊟ # @`) are always rendered on the right side via `statusModeIcons()`. `@` indicates blame gutter active via `B` toggle. `` indicates tree/TOC pane hidden via `t` toggle. Active modes use `StatusFg`, inactive use `Muted` — both via raw ANSI fg sequences. Graceful degradation on narrow terminals drops left segments: search position first (`statusSegmentsNoSearch`), then line number and hunk info (`statusSegmentsMinimal`), then truncates filename.
102107
- Search and hunk navigation both use `centerViewportOnCursor()` to center the target in the middle of the viewport. Use `syncViewportToCursor()` only for cursor movements that should keep the cursor barely visible (j/k scrolling).
103108
- Single-file mode (`m.singleFile`): when diff has exactly one file, tree pane is hidden, `treeWidth = 0`, diff gets full width (`m.width - 2` for borders, content width `m.width - 3`). Pane-switching keys (tab, h, l) and file navigation (n/p, f) become no-ops. Search nav (n/N) still works. Detection happens in `handleFilesLoaded`. Exception: when the file is markdown and full-context (all `ChangeContext` lines), an `mdTOC` pane replaces the tree pane with header navigation — see `ui/mdtoc.go`.
104109
- Tree pane toggle (`t` key): `m.treeHidden` hides the tree/TOC pane and gives diff full width. Orthogonal to `singleFile` — sets `treeWidth = 0`, forces `focus = paneDiff`, blocks `togglePane()`/`handleSwitchToTree()`. `handleViewToggle()` dispatches `v`, `w`, `t`, and `L` keys. `handleFileLoaded` respects `treeHidden` when setting up mdTOC layout.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Built for a specific use case: reviewing code changes, plans, and documents with
1111
- Collapsed diff mode: shows final text with change markers, toggle with `v`
1212
- Word wrap mode: wraps long lines at viewport boundary with `` continuation markers, toggle with `w`
1313
- Line numbers: side-by-side old/new line number gutter, toggle with `L`
14+
- Git blame gutter: shows author name and commit age per line, toggle with `B`
1415
- Annotate any line in the diff (added, removed, or context) plus file-level notes
1516
- Single-file auto-detection: when a diff contains exactly one file, hides the tree pane and gives full terminal width to the diff view
1617
- Two-pane TUI: file tree (left) + colorized diff viewport (right)
@@ -397,6 +398,7 @@ This mode activates when all three conditions are met: single file, markdown ext
397398
| `w` | Toggle word wrap (long lines wrap with `` continuation markers) |
398399
| `t` | Toggle tree/TOC pane visibility (gives diff full terminal width) |
399400
| `L` | Toggle line numbers (side-by-side old/new numbers in gutter) |
401+
| `B` | Toggle git blame gutter (author name + commit age per line) |
400402
| `.` | Expand/collapse individual hunk under cursor (collapsed mode only) |
401403
| `f` | Toggle filter: all files / annotated only (shown when annotations exist) |
402404
| `?` | Toggle help overlay showing all keybindings |

cmd/revdiff/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,16 @@ func run(opts options) error {
280280
keysPath = defaultKeysPath()
281281
}
282282
km := keymap.LoadOrDefault(keysPath)
283+
284+
// blame is only available when git is present
285+
var blamer ui.Blamer
286+
if gitErr == nil {
287+
blamer = diff.NewGit(gitRoot)
288+
}
289+
283290
model := ui.NewModel(renderer, store, hl, ui.ModelConfig{
284291
Keymap: km,
292+
Blamer: blamer,
285293
NoColors: opts.NoColors,
286294
NoStatusBar: opts.NoStatusBar,
287295
NoConfirmDiscard: opts.NoConfirmDiscard,

diff/blame.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package diff
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"strconv"
8+
"strings"
9+
"time"
10+
)
11+
12+
// BlameLine holds blame information for a single line of a file.
13+
type BlameLine struct {
14+
Author string
15+
Time time.Time
16+
}
17+
18+
// FileBlame returns blame information for each line of the rendered side of a file diff.
19+
// For unstaged single-ref diffs this is the worktree; for two-ref diffs this is the target ref.
20+
// For staged diffs this is the index snapshot. The returned map is keyed by 1-based line
21+
// number (matching DiffLine.NewNum).
22+
func (g *Git) FileBlame(ref string, file string, staged bool) (map[int]BlameLine, error) {
23+
args := []string{"blame", "--line-porcelain"}
24+
if staged {
25+
indexContent, err := g.runGit("show", ":"+file)
26+
if err != nil {
27+
return nil, fmt.Errorf("read index contents for %s: %w", file, err)
28+
}
29+
30+
tmp, err := os.CreateTemp("", "revdiff-blame-*")
31+
if err != nil {
32+
return nil, fmt.Errorf("create temp blame file for %s: %w", file, err)
33+
}
34+
tmpName := tmp.Name()
35+
defer os.Remove(tmpName)
36+
if _, err := tmp.WriteString(indexContent); err != nil {
37+
tmp.Close()
38+
return nil, fmt.Errorf("write temp blame file for %s: %w", file, err)
39+
}
40+
if err := tmp.Close(); err != nil {
41+
return nil, fmt.Errorf("close temp blame file for %s: %w", file, err)
42+
}
43+
44+
args = append(args, "--contents", tmpName)
45+
} else if targetRef := blameTargetRef(ref); targetRef != "" {
46+
args = append(args, targetRef)
47+
}
48+
args = append(args, "--", file)
49+
out, err := g.runGit(args...)
50+
if err != nil {
51+
return nil, fmt.Errorf("blame %s: %w", file, err)
52+
}
53+
return parseBlame(out)
54+
}
55+
56+
func blameTargetRef(ref string) string {
57+
parts := strings.Split(ref, "..")
58+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
59+
return ""
60+
}
61+
return parts[1]
62+
}
63+
64+
// parseBlame parses git blame --line-porcelain output into a map of line number to BlameLine.
65+
func parseBlame(raw string) (map[int]BlameLine, error) {
66+
result := make(map[int]BlameLine)
67+
scanner := bufio.NewScanner(strings.NewReader(raw))
68+
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), 1024*1024)
69+
70+
var lineNum int
71+
var author string
72+
var authorTime time.Time
73+
74+
for scanner.Scan() {
75+
line := scanner.Text()
76+
77+
// header line: <40-hex-hash> <orig_line> <final_line> [<group_lines>]
78+
if len(line) >= 40 && isHexString(line[:40]) {
79+
parts := strings.Fields(line)
80+
if len(parts) >= 3 {
81+
if n, err := strconv.Atoi(parts[2]); err == nil {
82+
lineNum = n
83+
}
84+
}
85+
author = ""
86+
authorTime = time.Time{}
87+
continue
88+
}
89+
90+
if v, ok := strings.CutPrefix(line, "author "); ok {
91+
author = v
92+
continue
93+
}
94+
95+
if v, ok := strings.CutPrefix(line, "author-time "); ok {
96+
if epoch, err := strconv.ParseInt(v, 10, 64); err == nil {
97+
authorTime = time.Unix(epoch, 0)
98+
}
99+
continue
100+
}
101+
102+
// content line (starts with tab) marks end of entry
103+
if strings.HasPrefix(line, "\t") && lineNum > 0 {
104+
result[lineNum] = BlameLine{Author: author, Time: authorTime}
105+
continue
106+
}
107+
}
108+
109+
if err := scanner.Err(); err != nil {
110+
return nil, fmt.Errorf("scan blame output: %w", err)
111+
}
112+
return result, nil
113+
}
114+
115+
// isHexString returns true if all characters in s are hexadecimal digits.
116+
func isHexString(s string) bool {
117+
if s == "" {
118+
return false
119+
}
120+
for _, c := range s {
121+
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
122+
return false
123+
}
124+
}
125+
return true
126+
}
127+
128+
// RelativeAge formats a timestamp as a compact relative age string (3 chars wide).
129+
// Examples: " 5m" (minutes), " 3h" (hours), " 2d" (days), " 1w" (weeks), " 4M" (months), " 2y" (years).
130+
func RelativeAge(t, now time.Time) string {
131+
if t.IsZero() {
132+
return " ?"
133+
}
134+
d := max(0, now.Sub(t))
135+
switch {
136+
case d < time.Hour:
137+
return fmt.Sprintf("%2dm", max(1, int(d.Minutes())))
138+
case d < 24*time.Hour:
139+
return fmt.Sprintf("%2dh", int(d.Hours()))
140+
case d < 7*24*time.Hour:
141+
return fmt.Sprintf("%2dd", int(d.Hours()/24))
142+
case d < 30*24*time.Hour:
143+
return fmt.Sprintf("%2dw", int(d.Hours()/(24*7)))
144+
case d < 365*24*time.Hour:
145+
return fmt.Sprintf("%2dM", int(d.Hours()/(24*30)))
146+
default:
147+
return fmt.Sprintf("%2dy", int(d.Hours()/(24*365)))
148+
}
149+
}

diff/blame_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package diff
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestParseBlame(t *testing.T) {
12+
// simulated git blame --line-porcelain output for a 3-line file
13+
raw := "abc1234567890123456789012345678901234567 1 1 2\n" +
14+
"author Alice\n" +
15+
"author-mail <alice@example.com>\n" +
16+
"author-time 1700000000\n" +
17+
"author-tz +0000\n" +
18+
"committer Alice\n" +
19+
"committer-mail <alice@example.com>\n" +
20+
"committer-time 1700000000\n" +
21+
"committer-tz +0000\n" +
22+
"summary initial commit\n" +
23+
"filename main.go\n" +
24+
"\tpackage main\n" +
25+
"abc1234567890123456789012345678901234567 1 2\n" +
26+
"author Alice\n" +
27+
"author-mail <alice@example.com>\n" +
28+
"author-time 1700000000\n" +
29+
"author-tz +0000\n" +
30+
"committer Alice\n" +
31+
"committer-mail <alice@example.com>\n" +
32+
"committer-time 1700000000\n" +
33+
"committer-tz +0000\n" +
34+
"summary initial commit\n" +
35+
"filename main.go\n" +
36+
"\t\n" +
37+
"def4567890123456789012345678901234567890 3 3 1\n" +
38+
"author Bob\n" +
39+
"author-mail <bob@example.com>\n" +
40+
"author-time 1710000000\n" +
41+
"author-tz -0500\n" +
42+
"committer Bob\n" +
43+
"committer-mail <bob@example.com>\n" +
44+
"committer-time 1710000000\n" +
45+
"committer-tz -0500\n" +
46+
"summary add feature\n" +
47+
"previous abc1234567890123456789012345678901234567 main.go\n" +
48+
"filename main.go\n" +
49+
"\tfunc main() {}\n"
50+
51+
result, err := parseBlame(raw)
52+
require.NoError(t, err)
53+
assert.Len(t, result, 3)
54+
55+
assert.Equal(t, "Alice", result[1].Author)
56+
assert.Equal(t, time.Unix(1700000000, 0), result[1].Time)
57+
58+
assert.Equal(t, "Alice", result[2].Author)
59+
assert.Equal(t, time.Unix(1700000000, 0), result[2].Time)
60+
61+
assert.Equal(t, "Bob", result[3].Author)
62+
assert.Equal(t, time.Unix(1710000000, 0), result[3].Time)
63+
}
64+
65+
func TestParseBlame_empty(t *testing.T) {
66+
result, err := parseBlame("")
67+
require.NoError(t, err)
68+
assert.Empty(t, result)
69+
}
70+
71+
func TestIsHexString(t *testing.T) {
72+
assert.True(t, isHexString("abcdef0123456789"))
73+
assert.True(t, isHexString("ABCDEF"))
74+
assert.False(t, isHexString("xyz"))
75+
assert.False(t, isHexString(""))
76+
}
77+
78+
func TestRelativeAge(t *testing.T) {
79+
now := time.Date(2026, 4, 6, 12, 0, 0, 0, time.UTC)
80+
81+
tests := []struct {
82+
name string
83+
t time.Time
84+
want string
85+
}{
86+
{"zero", time.Time{}, " ?"},
87+
{"30 seconds", now.Add(-30 * time.Second), " 1m"},
88+
{"5 minutes", now.Add(-5 * time.Minute), " 5m"},
89+
{"59 minutes", now.Add(-59 * time.Minute), "59m"},
90+
{"1 hour", now.Add(-1 * time.Hour), " 1h"},
91+
{"23 hours", now.Add(-23 * time.Hour), "23h"},
92+
{"1 day", now.Add(-24 * time.Hour), " 1d"},
93+
{"6 days", now.Add(-6 * 24 * time.Hour), " 6d"},
94+
{"1 week", now.Add(-7 * 24 * time.Hour), " 1w"},
95+
{"3 weeks", now.Add(-21 * 24 * time.Hour), " 3w"},
96+
{"2 months", now.Add(-60 * 24 * time.Hour), " 2M"},
97+
{"11 months", now.Add(-330 * 24 * time.Hour), "11M"},
98+
{"1 year", now.Add(-365 * 24 * time.Hour), " 1y"},
99+
{"3 years", now.Add(-3 * 365 * 24 * time.Hour), " 3y"},
100+
{"future", now.Add(1 * time.Hour), " 1m"},
101+
}
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
got := RelativeAge(tt.t, now)
105+
assert.Equal(t, tt.want, got)
106+
assert.Len(t, got, 3, "age string should be exactly 3 chars wide")
107+
})
108+
}
109+
}
110+
111+
func TestGit_FileBlame_UsesWorktreeForSingleRefDiffs(t *testing.T) {
112+
dir := setupTestRepo(t)
113+
g := NewGit(dir)
114+
115+
writeFile(t, dir, "f.txt", "one\ntwo\n")
116+
gitCmd(t, dir, "add", "f.txt")
117+
gitCmd(t, dir, "commit", "-m", "initial")
118+
119+
writeFile(t, dir, "f.txt", "one\ntwo committed\n")
120+
gitCmd(t, dir, "add", "f.txt")
121+
gitCmd(t, dir, "commit", "-m", "second")
122+
123+
writeFile(t, dir, "f.txt", "one\ntwo worktree\n")
124+
125+
result, err := g.FileBlame("HEAD~1", "f.txt", false)
126+
require.NoError(t, err)
127+
require.Len(t, result, 2)
128+
129+
assert.Equal(t, "Test", result[1].Author)
130+
assert.Equal(t, "Not Committed Yet", result[2].Author)
131+
}
132+
133+
func TestGit_FileBlame_UsesTargetRefForTwoRefDiffs(t *testing.T) {
134+
dir := setupTestRepo(t)
135+
g := NewGit(dir)
136+
137+
writeFile(t, dir, "f.txt", "one\ntwo\n")
138+
gitCmd(t, dir, "add", "f.txt")
139+
gitCmd(t, dir, "commit", "-m", "initial")
140+
141+
writeFile(t, dir, "f.txt", "one\ntwo target\n")
142+
gitCmd(t, dir, "add", "f.txt")
143+
gitCmd(t, dir, "commit", "-m", "target")
144+
145+
writeFile(t, dir, "f.txt", "one\ntwo worktree\n")
146+
147+
result, err := g.FileBlame("HEAD~1..HEAD", "f.txt", false)
148+
require.NoError(t, err)
149+
require.Len(t, result, 2)
150+
151+
assert.Equal(t, "Test", result[1].Author)
152+
assert.Equal(t, "Test", result[2].Author)
153+
}
154+
155+
func TestGit_FileBlame_UsesIndexForStagedDiffs(t *testing.T) {
156+
dir := setupTestRepo(t)
157+
g := NewGit(dir)
158+
159+
writeFile(t, dir, "f.txt", "one\ntwo\n")
160+
gitCmd(t, dir, "add", "f.txt")
161+
gitCmd(t, dir, "commit", "-m", "initial")
162+
163+
writeFile(t, dir, "f.txt", "one\ntwo staged\n")
164+
gitCmd(t, dir, "add", "f.txt")
165+
166+
writeFile(t, dir, "f.txt", "one\ntwo unstaged\n")
167+
168+
result, err := g.FileBlame("", "f.txt", true)
169+
require.NoError(t, err)
170+
require.Len(t, result, 2)
171+
172+
assert.Equal(t, "Test", result[1].Author)
173+
assert.Equal(t, "External file (--contents)", result[2].Author)
174+
}

0 commit comments

Comments
 (0)