Skip to content

Commit 38d2274

Browse files
feat(web): add file blame view to code browser (#1160)
* feat(web): add file blame view in code browser Wires up the /api/blame endpoint and CodeMirror gutter extension that together render a GitHub-style blame view in the code preview panel. URL state plumbing: - New BLAME_QUERY_PARAM ('blame') in browse/hooks/utils.ts; getBrowsePath forwards `blame: true` as ?blame=true. - page.tsx parses searchParams.blame and passes it to <CodePreviewPanel>. Server-side: - codePreviewPanel.tsx fetches blame data alongside file source via the existing parallel Promise.all when blame mode is enabled. - getFileBlameApi.ts now coalesces adjacent same-commit ranges in porcelain output so the API surface presents one range per visual region (porcelain emits a fresh group whenever source-line numbering is discontinuous in the commit's snapshot, even when the final-file lines are contiguous and attributed to the same commit). CodeMirror extension (blameGutterExtension.ts): - Renders a 400px-wide gutter to the left of line numbers (Prec.high to jump it ahead of the basicSetup lineNumbers gutter). - Each region's first line shows: relative date, author avatar (via the /api/avatar resolver), commit message, and a square-stack icon button for reblaming. Continuation lines are blank filler. - Cells are built with raw DOM + Tailwind class strings (avoids React mounting in CM markers, which had async-render flicker and lifecycle errors when wrapped in flushSync). - StateField + DecorationSet + gutterLineClass facet highlight every line of the cursor's commit (GitLens-style peer highlight) in both the source and gutter columns. - Reblame button navigates to the previous commit's hash + path, with blame mode preserved, using full revisionName context shift. pureCodePreviewPanel.tsx: - Accepts blame data, mounts the extension when present, supplies the click and reblame callbacks (router-driven via getBrowsePath). - Disables foldGutter, highlightActiveLine, and highlightActiveLineGutter in basicSetup when blame mode is on (they collide visually with the blame gutter and our cursor-driven peer highlight). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add Code/Blame toggle and file stats to code preview panel Adds a segmented toggle next to the path header that switches between "Code" (plain source) and "Blame" (gutter view). The toggle is hidden when previewRef is set since the preview banner handles that state. Also displays line count and file size next to the toggle (e.g. "1,246 lines · 42.6 KB"). Line count is derived from the source string (newlines, ignoring trailing); byte size uses Buffer.byteLength on the already-fetched source (no extra git call). Pulls in @radix-ui/react-toggle-group and a shadcn toggle-group.tsx component to render the segmented control. Items are styled with gap-0 + rounded-*-none + -ml-px to share a single border at the seam, matching the GitHub-style segmented control look. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: set activeBottomPanelTab to explore when goto refs / defs * chore(web): add CHANGELOG entry for file blame view Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * color gradient --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6faeb6d commit 38d2274

14 files changed

Lines changed: 695 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Added optional `path` query parameter to the `/api/diff` endpoint and `get_diff` MCP tool to restrict diffs to changes touching a specific file. [#1154](https://github.com/sourcebot-dev/sourcebot/pull/1154)
1515
- Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157)
1616
- Added `/api/blame` to the public API to fetch per-line blame information for a file at a given revision. [#1158](https://github.com/sourcebot-dev/sourcebot/pull/1158)
17+
- Added a file blame view to the code browser, with a Code / Blame toggle, cursor-driven peer-line highlighting, and a reblame button to walk back through history. [#1160](https://github.com/sourcebot-dev/sourcebot/pull/1160)
1718

1819
### Changed
1920
- Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159)

packages/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
"@radix-ui/react-switch": "^1.2.4",
8787
"@radix-ui/react-tabs": "^1.1.2",
8888
"@radix-ui/react-toast": "^1.2.2",
89-
"@radix-ui/react-toggle": "^1.1.0",
89+
"@radix-ui/react-toggle": "^1.1.10",
90+
"@radix-ui/react-toggle-group": "^1.1.11",
9091
"@radix-ui/react-tooltip": "^1.1.4",
9192
"@react-email/components": "^1.0.2",
9293
"@react-email/render": "^2.0.0",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Shared color ramp for the age-of-commit indicator. Used by the blame gutter
2+
// (left border of each cell) and the legend rendered next to the toolbar.
3+
//
4+
// Tailwind's JIT scanner reads class names from source, so each class must
5+
// appear as a complete literal string. Don't try to construct these via
6+
// template strings.
7+
8+
export const BLAME_AGE_BUCKET_COUNT = 10;
9+
10+
// In dark mode the ramp is flipped: pale shades (amber-50/100) are
11+
// high-contrast against a dark background, dark shades blend in. We want
12+
// "newer" to pop visually in both themes, so the dark-mode bucket-0 (oldest)
13+
// is amber-900 (low contrast → fades) and dark-mode bucket-9 (newest) is
14+
// amber-50 (high contrast → pops). The light-mode ramp stays unchanged.
15+
export const BLAME_AGE_BG_CLASSES = [
16+
'bg-slate-50 dark:bg-slate-900',
17+
'bg-slate-100 dark:bg-slate-800',
18+
'bg-slate-200 dark:bg-slate-700',
19+
'bg-slate-300 dark:bg-slate-600',
20+
'bg-slate-400 dark:bg-slate-500',
21+
'bg-slate-500 dark:bg-slate-400',
22+
'bg-slate-600 dark:bg-slate-300',
23+
'bg-slate-700 dark:bg-slate-200',
24+
'bg-slate-800 dark:bg-slate-100',
25+
'bg-slate-900 dark:bg-slate-50',
26+
] as const;
27+
28+
/**
29+
* Linear time mapping: given a commit date (ISO 8601) and the file's overall
30+
* date range, returns a bucket 0..9 (palest..darkest). Clamps out-of-range
31+
* inputs (e.g., clock-skewed future dates) to the endpoints.
32+
*/
33+
export const computeAgeBucket = (
34+
isoDate: string,
35+
oldestMs: number,
36+
newestMs: number,
37+
): number => {
38+
const max = BLAME_AGE_BUCKET_COUNT - 1;
39+
if (newestMs === oldestMs) {
40+
return max;
41+
}
42+
const t = new Date(isoDate).getTime();
43+
const ratio = (t - oldestMs) / (newestMs - oldestMs);
44+
const bucket = Math.floor(ratio * max);
45+
return Math.max(0, Math.min(max, bucket));
46+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { BLAME_AGE_BG_CLASSES } from "./blameAgeColors";
2+
import { cn } from "@/lib/utils";
3+
4+
export const BlameAgeLegend = () => {
5+
return (
6+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
7+
<span>Older</span>
8+
<div className="flex items-center gap-px">
9+
{BLAME_AGE_BG_CLASSES.map((bg, i) => (
10+
<div
11+
key={i}
12+
className={cn('h-2 w-2', bg)}
13+
/>
14+
))}
15+
</div>
16+
<span>Newer</span>
17+
</div>
18+
);
19+
};
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import { Decoration, DecorationSet, EditorView, gutter, gutterLineClass, GutterMarker } from "@codemirror/view";
2+
import { EditorState, Extension, Prec, Range as CMRange, RangeSet, StateField } from "@codemirror/state";
3+
import { formatDistanceToNowStrict } from "date-fns";
4+
import type { FileBlameResponse } from "@/features/git";
5+
import { cn } from "@/lib/utils";
6+
import { BLAME_AGE_BG_CLASSES, computeAgeBucket } from "./blameAgeColors";
7+
8+
type LineEntry = {
9+
hash: string;
10+
// Set only on the first line of a contiguous range; null on continuation
11+
// lines so they render as empty filler cells.
12+
message: string | null;
13+
date: string | null;
14+
authorEmail: string | null;
15+
// Pointer to the prior commit in the blame walk, used by the reblame
16+
// button. Absent when the commit introduced the lines.
17+
previous: { hash: string; path: string } | null;
18+
// True for first-line cells except line 1 of the file, so the divider
19+
// border doesn't render at the very top of the gutter.
20+
showStartBorder: boolean;
21+
// 0..9 bucket for the age-of-commit indicator stripe. Same value across
22+
// every line of a region (continuation lines included).
23+
ageBucket: number;
24+
};
25+
26+
// @see: https://lucide.dev/icons/file-stack
27+
const FILE_STACK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-stack-icon lucide-file-stack"><path d="M11 21a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1"/><path d="M16 16a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1"/><path d="M21 6a2 2 0 0 0-.586-1.414l-2-2A2 2 0 0 0 17 2h-3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1z"/></svg>'
28+
29+
30+
const buildCellDom = (
31+
entry: LineEntry,
32+
onCommitClick: (hash: string) => void,
33+
onReblameClick: (previous: { hash: string; path: string }) => void,
34+
): HTMLElement => {
35+
const cell = document.createElement('div');
36+
// `relative` so the absolutely-positioned age stripe inside has a
37+
// positioning context. The stripe is a child <div> rather than a
38+
// border-left because tailwind-merge collapses any same-group border-color
39+
// class (e.g. `border-border` on the region divider) with the per-side
40+
// amber color, dropping the stripe on first-line cells.
41+
cell.className = cn(
42+
'relative flex items-start h-full pl-2 pr-2 overflow-hidden text-xs text-muted-foreground',
43+
entry.showStartBorder && 'border-t border-border',
44+
);
45+
46+
const stripe = document.createElement('div');
47+
stripe.className = cn(
48+
'absolute inset-y-0 left-0 w-0.5',
49+
BLAME_AGE_BG_CLASSES[entry.ageBucket],
50+
);
51+
cell.appendChild(stripe);
52+
53+
if (entry.message === null || entry.date === null) {
54+
// Continuation line — empty cell with a non-breaking space so the row
55+
// still occupies its full line height.
56+
cell.appendChild(document.createTextNode(' '));
57+
return cell;
58+
}
59+
60+
const dateEl = document.createElement('span');
61+
dateEl.className = 'flex-shrink-0 w-24 truncate opacity-70 mr-1';
62+
dateEl.textContent = formatDistanceToNowStrict(new Date(entry.date), { addSuffix: true });
63+
cell.appendChild(dateEl);
64+
65+
// Avatar replicates UserAvatar's structure inline. Goes through the same
66+
// /api/avatar resolver so profile pictures and identicons share the same
67+
// browser cache as the rest of the app.
68+
const avatarWrap = document.createElement('span');
69+
avatarWrap.className = 'relative flex h-4 w-4 shrink-0 overflow-hidden rounded-full bg-muted mr-2';
70+
if (entry.authorEmail) {
71+
const avatarImg = document.createElement('img');
72+
avatarImg.className = 'aspect-square h-full w-full';
73+
avatarImg.src = `/api/avatar?email=${encodeURIComponent(entry.authorEmail)}`;
74+
avatarImg.alt = '';
75+
avatarWrap.appendChild(avatarImg);
76+
}
77+
cell.appendChild(avatarWrap);
78+
79+
const messageEl = document.createElement('button');
80+
messageEl.type = 'button';
81+
messageEl.className = 'flex-1 min-w-0 truncate text-left bg-transparent border-0 p-0 m-0 font-[inherit] text-inherit cursor-pointer hover:text-foreground hover:underline';
82+
messageEl.textContent = entry.message;
83+
messageEl.addEventListener('click', () => onCommitClick(entry.hash));
84+
cell.appendChild(messageEl);
85+
86+
if (entry.previous) {
87+
const previous = entry.previous;
88+
const reblameBtn = document.createElement('button');
89+
reblameBtn.type = 'button';
90+
reblameBtn.title = `Blame prior to ${previous.hash.slice(0, 7)}`;
91+
reblameBtn.className = 'flex-shrink-0 ml-1 p-0.5 bg-transparent border-0 cursor-pointer text-muted-foreground hover:text-foreground';
92+
reblameBtn.innerHTML = FILE_STACK_SVG;
93+
reblameBtn.addEventListener('click', (e) => {
94+
e.stopPropagation();
95+
onReblameClick(previous);
96+
});
97+
cell.appendChild(reblameBtn);
98+
}
99+
100+
return cell;
101+
};
102+
103+
class BlameMarker extends GutterMarker {
104+
constructor(
105+
readonly entry: LineEntry,
106+
readonly onCommitClick: (hash: string) => void,
107+
readonly onReblameClick: (previous: { hash: string; path: string }) => void,
108+
) {
109+
super();
110+
}
111+
112+
eq(other: GutterMarker): boolean {
113+
if (!(other instanceof BlameMarker)) {
114+
return false;
115+
}
116+
const a = this.entry;
117+
const b = other.entry;
118+
return (
119+
a.hash === b.hash &&
120+
a.message === b.message &&
121+
a.date === b.date &&
122+
a.authorEmail === b.authorEmail &&
123+
a.showStartBorder === b.showStartBorder &&
124+
a.ageBucket === b.ageBucket &&
125+
a.previous?.hash === b.previous?.hash &&
126+
a.previous?.path === b.previous?.path
127+
);
128+
}
129+
130+
toDOM(): HTMLElement {
131+
return buildCellDom(this.entry, this.onCommitClick, this.onReblameClick);
132+
}
133+
}
134+
135+
// Decoration applied to source-area lines that share the active commit, and a
136+
// matching gutter marker so the blame column gets the same highlight.
137+
const activeLineDecoration = Decoration.line({
138+
attributes: { class: 'cm-blame-active-line' },
139+
});
140+
const activeGutterMarker = new (class extends GutterMarker {
141+
elementClass = 'cm-blame-active-line';
142+
})();
143+
144+
const computeActive = (
145+
state: EditorState,
146+
lineIndex: Map<number, LineEntry>,
147+
commitToLines: Map<string, number[]>,
148+
): { decorations: DecorationSet; gutterMarkers: RangeSet<GutterMarker> } => {
149+
const cursorLine = state.doc.lineAt(state.selection.main.head).number;
150+
const activeHash = lineIndex.get(cursorLine)?.hash;
151+
if (!activeHash) {
152+
return { decorations: Decoration.none, gutterMarkers: RangeSet.empty };
153+
}
154+
155+
const lines = commitToLines.get(activeHash) ?? [];
156+
const decoRanges: CMRange<Decoration>[] = [];
157+
const markerRanges: CMRange<GutterMarker>[] = [];
158+
159+
for (const lineNumber of lines) {
160+
if (lineNumber > state.doc.lines) {
161+
continue;
162+
}
163+
const line = state.doc.line(lineNumber);
164+
decoRanges.push(activeLineDecoration.range(line.from));
165+
markerRanges.push(activeGutterMarker.range(line.from));
166+
}
167+
168+
return {
169+
decorations: Decoration.set(decoRanges),
170+
gutterMarkers: RangeSet.of(markerRanges),
171+
};
172+
};
173+
174+
const buildLineIndex = (blame: FileBlameResponse): Map<number, LineEntry> => {
175+
// Compute the file's overall date range so each commit's age can be
176+
// mapped to a 0..9 bucket. We assume blame.commits' `date` fields are
177+
// ISO 8601 strings.
178+
const dateMs = Object.values(blame.commits)
179+
.map(c => new Date(c.date).getTime())
180+
.filter(t => Number.isFinite(t));
181+
const oldestMs = dateMs.length > 0 ? Math.min(...dateMs) : 0;
182+
const newestMs = dateMs.length > 0 ? Math.max(...dateMs) : 0;
183+
184+
// Per-commit bucket cache so every line of a region gets the same value
185+
// (and we don't recompute for each line).
186+
const bucketByHash = new Map<string, number>();
187+
for (const [hash, commit] of Object.entries(blame.commits)) {
188+
bucketByHash.set(hash, computeAgeBucket(commit.date, oldestMs, newestMs));
189+
}
190+
191+
const index = new Map<number, LineEntry>();
192+
for (const range of blame.ranges) {
193+
const commit = blame.commits[range.hash];
194+
const ageBucket = bucketByHash.get(range.hash) ?? 0;
195+
for (let i = 0; i < range.lineCount; i++) {
196+
const lineNumber = range.startLine + i;
197+
const isFirstLineOfRange = i === 0;
198+
const showStartBorder = isFirstLineOfRange && lineNumber > 1;
199+
if (isFirstLineOfRange && commit) {
200+
index.set(lineNumber, {
201+
hash: range.hash,
202+
message: commit.message,
203+
date: commit.date,
204+
authorEmail: commit.authorEmail,
205+
previous: commit.previous ?? null,
206+
showStartBorder,
207+
ageBucket,
208+
});
209+
} else {
210+
index.set(lineNumber, {
211+
hash: range.hash,
212+
message: null,
213+
date: null,
214+
authorEmail: null,
215+
previous: null,
216+
showStartBorder,
217+
ageBucket,
218+
});
219+
}
220+
}
221+
}
222+
return index;
223+
};
224+
225+
const blameTheme = EditorView.theme({
226+
'.cm-blame-gutter': {
227+
width: '400px',
228+
backgroundColor: 'var(--background)',
229+
borderRight: '1px solid var(--border)',
230+
userSelect: 'none',
231+
},
232+
'.cm-blame-active-line': {
233+
backgroundColor: 'var(--accent)',
234+
},
235+
});
236+
237+
export const blameGutterExtension = (
238+
blame: FileBlameResponse,
239+
onCommitClick: (hash: string) => void,
240+
onReblameClick: (previous: { hash: string; path: string }) => void,
241+
): Extension => {
242+
const lineIndex = buildLineIndex(blame);
243+
244+
// Reverse index: commit hash → ascending list of line numbers attributed to
245+
// that commit. Used to highlight every line of the active commit when the
246+
// cursor is on one of them. Cheap to build (one pass over lineIndex, which
247+
// is itself iterated in line order).
248+
const commitToLines = new Map<string, number[]>();
249+
for (const [lineNumber, entry] of lineIndex) {
250+
const existing = commitToLines.get(entry.hash);
251+
if (existing) {
252+
existing.push(lineNumber);
253+
} else {
254+
commitToLines.set(entry.hash, [lineNumber]);
255+
}
256+
}
257+
258+
const activeBlameField = StateField.define<{
259+
decorations: DecorationSet;
260+
gutterMarkers: RangeSet<GutterMarker>;
261+
}>({
262+
create: state => computeActive(state, lineIndex, commitToLines),
263+
update(value, tr) {
264+
if (tr.docChanged || tr.selection) {
265+
return computeActive(tr.state, lineIndex, commitToLines);
266+
}
267+
return value;
268+
},
269+
provide: f => [
270+
EditorView.decorations.from(f, v => v.decorations),
271+
gutterLineClass.from(f, v => v.gutterMarkers),
272+
],
273+
});
274+
275+
return [
276+
activeBlameField,
277+
// Bump precedence so this gutter is registered before lineNumbers() from
278+
// basicSetup, placing the blame column to the left of line numbers.
279+
Prec.high(gutter({
280+
class: 'cm-blame-gutter',
281+
lineMarker(view, blockInfo) {
282+
const lineNumber = view.state.doc.lineAt(blockInfo.from).number;
283+
const entry = lineIndex.get(lineNumber);
284+
if (!entry) {
285+
return null;
286+
}
287+
return new BlameMarker(entry, onCommitClick, onReblameClick);
288+
},
289+
})),
290+
blameTheme,
291+
];
292+
};

0 commit comments

Comments
 (0)