Problem
When reviewing a diff, the reviewer often needs to know who last
touched the surrounding code and how old it is — both for routing
("who do I ask about this") and for risk assessment ("modifying
month-old churn is different from modifying year-stable code"). Git
provides git blame for this, but DiffViewer surfaces none of it
today.
Proposal
Add a two-layer blame surface to the diff pane:
Layer 1 — Gutter age heatmap (always-on when blame is enabled)
A thin (~4 px) colored band between the line number and the code,
colored by commit age:
- Recent (this week) — warm orange-red
- 1–3 months — amber / gold
- 3–6 months — olive
- 6 months – 1 year — neutral warm
- 1+ year — neutral gray
Local edits on the working-tree side render as a dashed teal band so
uncommitted lines are visually distinct from committed ones.
A small legend is reachable via a "?" affordance on the heatmap band
or via Settings.
Layer 2 — Sticky current-line blame strip (focus-driven)
A single-row strip below each pane reveals blame for the focused line:
▸ e4f5a6b R. Sundar 4 months ago "Render hunks asynchronously"
The strip is visible only when blame is enabled. Right-click on the
strip exposes the action surface:
- Compare this commit to its parent — launches a new DiffViewer
context (reuses the launch-context plumbing).
- Copy SHA
- Blame this file standalone — opens a file-blame view (out of
scope for this issue, but the menu entry reserves the slot).
Layer 3 — Per-line annotation (opt-in expert mode)
A separate, toggle-able "show full blame per line" mode that renders
SHA / author / relative date in a wider gutter for every line. This is
the classic Fork / git blame layout. It costs ~170 px per pane and
crowds side-by-side mode, so it's opt-in only, not the default.
Scope decisions
- Two-layer default (heatmap + strip), opt-in third layer
(per-line). A mockup comparison ruled out always-on per-line for
side-by-side mode.
- Working-tree side blame is computed against HEAD, with
uncommitted lines marked as "local edit" (dashed band + "uncommitted /
now" in the strip). Treating the working tree as if it were HEAD is
the simplest honest semantics.
- Background-thread computation.
LibGit2Sharp.Repository.Blame
can take hundreds of ms to multiple seconds on large files. Blame
must run off the UI thread (per AGENTS.md §9) and feed results into
the render pipeline incrementally — never block the diff itself.
- Per-(repo path, commit SHA, file path) cache. Blame is
expensive and only changes when commits are added. Cache lifetime
follows the existing repository-watcher invalidation surface.
- Settings. One toggle to enable the default surface (heatmap +
strip together). A second toggle for the opt-in per-line mode.
Reuses the existing settings persistence path.
Open questions
- Granularity of the default-surface toggle — single switch for
both layers, or separate heatmap-on / strip-on toggles. Single is
simpler; separate gives users who only want one or the other a
cleaner choice.
- Default state at first launch — both off (opt-in everything),
heatmap on / strip on but small, or heatmap off / strip on (least
noisy default that still adds value). Probably "both off" since
blame computation has a measurable cost on first open and we
shouldn't pay it for users who don't want it.
- Rename / copy detection knob.
git blame -C -M follows renames
and copies aggressively. LibGit2Sharp's default does not. Decide
the default explicitly — "follow renames" matches user intuition;
"don't follow" is faster.
- Heatmap color scale calibration. The age bands ("recent",
"1–3 months", etc.) are intuitive but somewhat arbitrary. May want
the scale to adapt to the repo's median commit age so a 5-year-old
repo doesn't render entirely in the "neutral gray" bucket.
- Click-through navigation from a blamed line. The right-click
"Compare commit to its parent" entry needs to play nicely with the
recent-contexts store — adding the launched context to recents on
every blame click might pollute the list.
- Inline mode behavior. The same two-layer surface should work in
inline mode, but only one heatmap band (the inline view has a
single editor), and the strip lives at the bottom of the single
pane.
Acceptance
- Settings toggles for the default surface (heatmap + strip) and the
per-line annotation mode.
- Heatmap band renders next to every line in both side-by-side and
inline modes when enabled.
- Sticky strip renders below the pane for the focused line, with
right-click → "Compare commit to its parent" working end-to-end.
- Blame computation runs on a background thread; the diff itself
never blocks waiting on blame.
- Per-(repo, SHA, path) blame cache invalidates when the
repository-watcher fires.
- Working-tree side renders uncommitted lines as "local edit" (dashed
band + uncommitted label).
- Per-line annotation mode renders correctly when toggled on.
- Tests cover: the blame-service layer (new), the cache invalidation
hook, the heatmap age-bucket assignment, and the
view-model surface that drives the strip.
Notes
Tier 3 from the feature brainstorm, but elevated by a deliberate
design pass — the two-layer composition is what makes blame fit
DiffViewer's "quiet by default" chrome philosophy without sacrificing
information density when the user asks for it. Scope: medium.
A mockup comparing baseline / A (strip alone) / B (heatmap alone) /
C (per-line) / A+B (recommended) was used to settle the composition.
The trade-off table:
| Option |
Visual noise |
Horizontal cost |
Info per line |
Discoverability |
| Baseline |
none |
0 px |
none |
n/a |
| A — Sticky strip |
low |
0 px |
full (focused line) |
needs click / hover |
| B — Heatmap |
low |
4 px |
age only |
needs legend |
| C — Per-line |
high |
~170 px |
full (every line) |
always visible |
| ★ A + B (this issue) |
low |
4 px |
age always + full focus |
heatmap + focus strip |
Related: the existing IExternalAppLauncher / right-click action
plumbing is the model for the strip's context menu. The recent
file-list refresh fix (v0.3) is the model for keeping blame state
intact across same-file refreshes.
Problem
When reviewing a diff, the reviewer often needs to know who last
touched the surrounding code and how old it is — both for routing
("who do I ask about this") and for risk assessment ("modifying
month-old churn is different from modifying year-stable code"). Git
provides
git blamefor this, but DiffViewer surfaces none of ittoday.
Proposal
Add a two-layer blame surface to the diff pane:
Layer 1 — Gutter age heatmap (always-on when blame is enabled)
A thin (~4 px) colored band between the line number and the code,
colored by commit age:
Local edits on the working-tree side render as a dashed teal band so
uncommitted lines are visually distinct from committed ones.
A small legend is reachable via a "?" affordance on the heatmap band
or via Settings.
Layer 2 — Sticky current-line blame strip (focus-driven)
A single-row strip below each pane reveals blame for the focused line:
The strip is visible only when blame is enabled. Right-click on the
strip exposes the action surface:
context (reuses the launch-context plumbing).
scope for this issue, but the menu entry reserves the slot).
Layer 3 — Per-line annotation (opt-in expert mode)
A separate, toggle-able "show full blame per line" mode that renders
SHA / author / relative date in a wider gutter for every line. This is
the classic Fork /
git blamelayout. It costs ~170 px per pane andcrowds side-by-side mode, so it's opt-in only, not the default.
Scope decisions
(per-line). A mockup comparison ruled out always-on per-line for
side-by-side mode.
uncommitted lines marked as "local edit" (dashed band + "uncommitted /
now" in the strip). Treating the working tree as if it were HEAD is
the simplest honest semantics.
LibGit2Sharp.Repository.Blamecan take hundreds of ms to multiple seconds on large files. Blame
must run off the UI thread (per
AGENTS.md§9) and feed results intothe render pipeline incrementally — never block the diff itself.
expensive and only changes when commits are added. Cache lifetime
follows the existing repository-watcher invalidation surface.
strip together). A second toggle for the opt-in per-line mode.
Reuses the existing settings persistence path.
Open questions
both layers, or separate heatmap-on / strip-on toggles. Single is
simpler; separate gives users who only want one or the other a
cleaner choice.
heatmap on / strip on but small, or heatmap off / strip on (least
noisy default that still adds value). Probably "both off" since
blame computation has a measurable cost on first open and we
shouldn't pay it for users who don't want it.
git blame -C -Mfollows renamesand copies aggressively. LibGit2Sharp's default does not. Decide
the default explicitly — "follow renames" matches user intuition;
"don't follow" is faster.
"1–3 months", etc.) are intuitive but somewhat arbitrary. May want
the scale to adapt to the repo's median commit age so a 5-year-old
repo doesn't render entirely in the "neutral gray" bucket.
"Compare commit to its parent" entry needs to play nicely with the
recent-contexts store — adding the launched context to recents on
every blame click might pollute the list.
inline mode, but only one heatmap band (the inline view has a
single editor), and the strip lives at the bottom of the single
pane.
Acceptance
per-line annotation mode.
inline modes when enabled.
right-click → "Compare commit to its parent" working end-to-end.
never blocks waiting on blame.
repository-watcher fires.
band + uncommitted label).
hook, the heatmap age-bucket assignment, and the
view-model surface that drives the strip.
Notes
Tier 3 from the feature brainstorm, but elevated by a deliberate
design pass — the two-layer composition is what makes blame fit
DiffViewer's "quiet by default" chrome philosophy without sacrificing
information density when the user asks for it. Scope: medium.
A mockup comparing baseline / A (strip alone) / B (heatmap alone) /
C (per-line) / A+B (recommended) was used to settle the composition.
The trade-off table:
Related: the existing
IExternalAppLauncher/ right-click actionplumbing is the model for the strip's context menu. The recent
file-list refresh fix (v0.3) is the model for keeping blame state
intact across same-file refreshes.