From c95d4d2a4b3803d4e02590022663344f297c81a7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 16:46:41 -0700 Subject: [PATCH 1/5] 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 . 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) --- .../codePreviewPanel/blameGutterExtension.ts | 256 ++++++++++++++++++ .../codePreviewPanel/codePreviewPanel.tsx | 21 +- .../codePreviewPanel/pureCodePreviewPanel.tsx | 48 +++- .../src/app/(app)/browse/[...path]/page.tsx | 3 + .../web/src/app/(app)/browse/hooks/utils.ts | 7 + .../web/src/features/git/getFileBlameApi.ts | 16 +- 6 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts new file mode 100644 index 000000000..77bd75ba2 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts @@ -0,0 +1,256 @@ +import { Decoration, DecorationSet, EditorView, gutter, gutterLineClass, GutterMarker } from "@codemirror/view"; +import { EditorState, Extension, Prec, Range as CMRange, RangeSet, StateField } from "@codemirror/state"; +import { formatDistanceToNowStrict } from "date-fns"; +import type { FileBlameResponse } from "@/features/git"; +import { cn } from "@/lib/utils"; + +type LineEntry = { + hash: string; + // Set only on the first line of a contiguous range; null on continuation + // lines so they render as empty filler cells. + message: string | null; + date: string | null; + authorEmail: string | null; + // Pointer to the prior commit in the blame walk, used by the reblame + // button. Absent when the commit introduced the lines. + previous: { hash: string; path: string } | null; + // True for first-line cells except line 1 of the file, so the divider + // border doesn't render at the very top of the gutter. + showStartBorder: boolean; +}; + +// @see: https://lucide.dev/icons/file-stack +const FILE_STACK_SVG = '' + + +const buildCellDom = ( + entry: LineEntry, + onCommitClick: (hash: string) => void, + onReblameClick: (previous: { hash: string; path: string }) => void, +): HTMLElement => { + const cell = document.createElement('div'); + cell.className = cn( + 'flex items-start h-full px-2 overflow-hidden text-xs text-muted-foreground', + entry.showStartBorder && 'border-t border-border', + ); + + if (entry.message === null || entry.date === null) { + // Continuation line — empty cell with a non-breaking space so the row + // still occupies its full line height. + cell.appendChild(document.createTextNode(' ')); + return cell; + } + + const dateEl = document.createElement('span'); + dateEl.className = 'flex-shrink-0 w-24 truncate opacity-70 mr-1'; + dateEl.textContent = formatDistanceToNowStrict(new Date(entry.date), { addSuffix: true }); + cell.appendChild(dateEl); + + // Avatar replicates UserAvatar's structure inline. Goes through the same + // /api/avatar resolver so profile pictures and identicons share the same + // browser cache as the rest of the app. + const avatarWrap = document.createElement('span'); + avatarWrap.className = 'relative flex h-4 w-4 shrink-0 overflow-hidden rounded-full bg-muted mr-2'; + if (entry.authorEmail) { + const avatarImg = document.createElement('img'); + avatarImg.className = 'aspect-square h-full w-full'; + avatarImg.src = `/api/avatar?email=${encodeURIComponent(entry.authorEmail)}`; + avatarImg.alt = ''; + avatarWrap.appendChild(avatarImg); + } + cell.appendChild(avatarWrap); + + const messageEl = document.createElement('button'); + messageEl.type = 'button'; + 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'; + messageEl.textContent = entry.message; + messageEl.addEventListener('click', () => onCommitClick(entry.hash)); + cell.appendChild(messageEl); + + if (entry.previous) { + const previous = entry.previous; + const reblameBtn = document.createElement('button'); + reblameBtn.type = 'button'; + reblameBtn.title = `Blame prior to ${previous.hash.slice(0, 7)}`; + reblameBtn.className = 'flex-shrink-0 ml-1 p-0.5 bg-transparent border-0 cursor-pointer text-muted-foreground hover:text-foreground'; + reblameBtn.innerHTML = FILE_STACK_SVG; + reblameBtn.addEventListener('click', (e) => { + e.stopPropagation(); + onReblameClick(previous); + }); + cell.appendChild(reblameBtn); + } + + return cell; +}; + +class BlameMarker extends GutterMarker { + constructor( + readonly entry: LineEntry, + readonly onCommitClick: (hash: string) => void, + readonly onReblameClick: (previous: { hash: string; path: string }) => void, + ) { + super(); + } + + eq(other: GutterMarker): boolean { + if (!(other instanceof BlameMarker)) { + return false; + } + const a = this.entry; + const b = other.entry; + return ( + a.hash === b.hash && + a.message === b.message && + a.date === b.date && + a.authorEmail === b.authorEmail && + a.showStartBorder === b.showStartBorder && + a.previous?.hash === b.previous?.hash && + a.previous?.path === b.previous?.path + ); + } + + toDOM(): HTMLElement { + return buildCellDom(this.entry, this.onCommitClick, this.onReblameClick); + } +} + +// Decoration applied to source-area lines that share the active commit, and a +// matching gutter marker so the blame column gets the same highlight. +const activeLineDecoration = Decoration.line({ + attributes: { class: 'cm-blame-active-line' }, +}); +const activeGutterMarker = new (class extends GutterMarker { + elementClass = 'cm-blame-active-line'; +})(); + +const computeActive = ( + state: EditorState, + lineIndex: Map, + commitToLines: Map, +): { decorations: DecorationSet; gutterMarkers: RangeSet } => { + const cursorLine = state.doc.lineAt(state.selection.main.head).number; + const activeHash = lineIndex.get(cursorLine)?.hash; + if (!activeHash) { + return { decorations: Decoration.none, gutterMarkers: RangeSet.empty }; + } + + const lines = commitToLines.get(activeHash) ?? []; + const decoRanges: CMRange[] = []; + const markerRanges: CMRange[] = []; + + for (const lineNumber of lines) { + if (lineNumber > state.doc.lines) { + continue; + } + const line = state.doc.line(lineNumber); + decoRanges.push(activeLineDecoration.range(line.from)); + markerRanges.push(activeGutterMarker.range(line.from)); + } + + return { + decorations: Decoration.set(decoRanges), + gutterMarkers: RangeSet.of(markerRanges), + }; +}; + +const buildLineIndex = (blame: FileBlameResponse): Map => { + const index = new Map(); + for (const range of blame.ranges) { + const commit = blame.commits[range.hash]; + for (let i = 0; i < range.lineCount; i++) { + const lineNumber = range.startLine + i; + const isFirstLineOfRange = i === 0; + const showStartBorder = isFirstLineOfRange && lineNumber > 1; + if (isFirstLineOfRange && commit) { + index.set(lineNumber, { + hash: range.hash, + message: commit.message, + date: commit.date, + authorEmail: commit.authorEmail, + previous: commit.previous ?? null, + showStartBorder, + }); + } else { + index.set(lineNumber, { + hash: range.hash, + message: null, + date: null, + authorEmail: null, + previous: null, + showStartBorder, + }); + } + } + } + return index; +}; + +const blameTheme = EditorView.theme({ + '.cm-blame-gutter': { + width: '400px', + backgroundColor: 'var(--background)', + borderRight: '1px solid var(--border)', + userSelect: 'none', + }, + '.cm-blame-active-line': { + backgroundColor: 'var(--accent)', + }, +}); + +export const blameGutterExtension = ( + blame: FileBlameResponse, + onCommitClick: (hash: string) => void, + onReblameClick: (previous: { hash: string; path: string }) => void, +): Extension => { + const lineIndex = buildLineIndex(blame); + + // Reverse index: commit hash → ascending list of line numbers attributed to + // that commit. Used to highlight every line of the active commit when the + // cursor is on one of them. Cheap to build (one pass over lineIndex, which + // is itself iterated in line order). + const commitToLines = new Map(); + for (const [lineNumber, entry] of lineIndex) { + const existing = commitToLines.get(entry.hash); + if (existing) { + existing.push(lineNumber); + } else { + commitToLines.set(entry.hash, [lineNumber]); + } + } + + const activeBlameField = StateField.define<{ + decorations: DecorationSet; + gutterMarkers: RangeSet; + }>({ + create: state => computeActive(state, lineIndex, commitToLines), + update(value, tr) { + if (tr.docChanged || tr.selection) { + return computeActive(tr.state, lineIndex, commitToLines); + } + return value; + }, + provide: f => [ + EditorView.decorations.from(f, v => v.decorations), + gutterLineClass.from(f, v => v.gutterMarkers), + ], + }); + + return [ + activeBlameField, + // Bump precedence so this gutter is registered before lineNumbers() from + // basicSetup, placing the blame column to the left of line numbers. + Prec.high(gutter({ + class: 'cm-blame-gutter', + lineMarker(view, blockInfo) { + const lineNumber = view.state.doc.lineAt(blockInfo.from).number; + const entry = lineIndex.get(lineNumber); + if (!entry) { + return null; + } + return new BlameMarker(entry, onCommitClick, onReblameClick); + }, + })), + blameTheme, + ]; +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx index bad7e67c9..294c2f0a0 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx @@ -9,7 +9,7 @@ import Image from "next/image"; import Link from "next/link"; import { getBrowsePath } from "../../../hooks/utils"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; -import { getFileSource } from '@/features/git'; +import { getFileBlame, getFileSource } from '@/features/git'; interface CodePreviewPanelProps { path: string; @@ -18,18 +18,28 @@ interface CodePreviewPanelProps { // When set, the file's content is fetched at this ref while the // surrounding browse context (path header) stays at `revisionName`. previewRef?: string; + // When true, fetch blame data alongside the file source and pass it to + // the editor so the blame gutter can render. + blame?: boolean; } -export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef }: CodePreviewPanelProps) => { +export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef, blame }: CodePreviewPanelProps) => { const contentRef = previewRef ?? revisionName; - const [fileSourceResponse, repoInfoResponse] = await Promise.all([ + const [fileSourceResponse, repoInfoResponse, blameResponse] = await Promise.all([ getFileSource({ path, repo: repoName, ref: contentRef, }, { source: 'sourcebot-web-client' }), getRepoInfoByName(repoName), + blame + ? getFileBlame({ + path, + repo: repoName, + ref: contentRef, + }, { source: 'sourcebot-web-client' }) + : Promise.resolve(undefined), ]); if (isServiceError(fileSourceResponse)) { @@ -40,6 +50,10 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe return
Error loading repo info: {repoInfoResponse.message}
} + if (blameResponse !== undefined && isServiceError(blameResponse)) { + return
Error loading blame: {blameResponse.message}
+ } + const codeHostInfo = getCodeHostInfoForRepo({ codeHostType: repoInfoResponse.codeHostType, name: repoInfoResponse.name, @@ -132,6 +146,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe repoName={repoName} path={path} revisionName={contentRef ?? 'HEAD'} + blame={blameResponse} /> ) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx index af260bd4b..cbd14eeba 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx @@ -10,10 +10,13 @@ import { useKeymapExtension } from "@/hooks/useKeymapExtension"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { search } from "@codemirror/search"; import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { EditorContextMenu } from "@/app/(app)/components/editorContextMenu"; -import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/(app)/browse/hooks/utils"; +import { BrowseHighlightRange, getBrowsePath, HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/(app)/browse/hooks/utils"; import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; +import { blameGutterExtension } from "./blameGutterExtension"; +import type { FileBlameResponse } from "@/features/git"; interface PureCodePreviewPanelProps { path: string; @@ -21,6 +24,7 @@ interface PureCodePreviewPanelProps { revisionName: string; source: string; language: string; + blame?: FileBlameResponse; } export const PureCodePreviewPanel = ({ @@ -29,12 +33,35 @@ export const PureCodePreviewPanel = ({ path, repoName, revisionName, + blame, }: PureCodePreviewPanelProps) => { const [editorRef, setEditorRef] = useState(null); const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); const [currentSelection, setCurrentSelection] = useState(); const keymapExtension = useKeymapExtension(editorRef?.view); const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const router = useRouter(); + + const handleBlameCommitClick = useCallback((hash: string) => { + router.push(getBrowsePath({ + repoName, + revisionName, + path, + pathType: 'blob', + previewRef: hash, + diff: true, + })); + }, [router, repoName, revisionName, path]); + + const handleBlameReblameClick = useCallback((previous: { hash: string; path: string }) => { + router.push(getBrowsePath({ + repoName, + revisionName: previous.hash, + path: previous.path, + pathType: 'blob', + blame: true, + })); + }, [router, repoName]); const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); const highlightRange = useMemo((): BrowseHighlightRange | undefined => { @@ -97,12 +124,20 @@ export const PureCodePreviewPanel = ({ }), highlightRange ? rangeHighlightingExtension(highlightRange) : [], hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], + blame ? blameGutterExtension( + blame, + handleBlameCommitClick, + handleBlameReblameClick + ) : [], ]; }, [ keymapExtension, languageExtension, highlightRange, hasCodeNavEntitlement, + blame, + handleBlameCommitClick, + handleBlameReblameClick, ]); // Scroll the highlighted range into view. @@ -129,7 +164,7 @@ export const PureCodePreviewPanel = ({ const viewport = editorRef.view.viewport; const isInView = from >= viewport.from && to <= viewport.to; const scrollStrategy = isInView ? "nearest" : "center"; - + editorRef.view?.dispatch({ effects: [ EditorView.scrollIntoView(selection, { y: scrollStrategy }), @@ -148,6 +183,13 @@ export const PureCodePreviewPanel = ({ extensions={extensions} readOnly={true} theme={theme} + basicSetup={ + blame ? { + foldGutter: false, + highlightActiveLine: false, + highlightActiveLineGutter: false, + } : true + } > {editorRef && editorRef.view && currentSelection && ( ; } @@ -109,6 +110,7 @@ export default async function BrowsePage(props: BrowsePageProps) { const until = searchParams.until || undefined; const previewRef = searchParams.ref || undefined; const isDiffMode = searchParams.diff === 'true'; + const isBlameMode = searchParams.blame === 'true'; return (
@@ -132,6 +134,7 @@ export default async function BrowsePage(props: BrowsePageProps) { repoName={repoName} revisionName={revisionName} previewRef={previewRef} + blame={isBlameMode} /> ) ) : browseProps.pathType === 'commits' ? ( diff --git a/packages/web/src/app/(app)/browse/hooks/utils.ts b/packages/web/src/app/(app)/browse/hooks/utils.ts index 81ed29496..7ea1863c6 100644 --- a/packages/web/src/app/(app)/browse/hooks/utils.ts +++ b/packages/web/src/app/(app)/browse/hooks/utils.ts @@ -3,6 +3,7 @@ import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvide export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange'; export const PREVIEW_REF_QUERY_PARAM = 'ref'; export const DIFF_QUERY_PARAM = 'diff'; +export const BLAME_QUERY_PARAM = 'blame'; export type BrowseHighlightRange = { start: { lineNumber: number; column: number; }; @@ -28,6 +29,8 @@ type BlobProps = BaseProps & { // When true, render the focused commit diff (for `previewRef`) instead of // the file's source. Only meaningful alongside `previewRef`. diff?: boolean; + // When true, render blame annotations alongside the file source. + blame?: boolean; } type TreeProps = BaseProps & { @@ -165,6 +168,10 @@ export const getBrowsePath = (props: BrowseProps) => { params.set(DIFF_QUERY_PARAM, 'true'); } + if (pathType === 'blob' && props.blame) { + params.set(BLAME_QUERY_PARAM, 'true'); + } + if (setBrowseState) { params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState)); } diff --git a/packages/web/src/features/git/getFileBlameApi.ts b/packages/web/src/features/git/getFileBlameApi.ts index ae12a3a0d..a7a63006a 100644 --- a/packages/web/src/features/git/getFileBlameApi.ts +++ b/packages/web/src/features/git/getFileBlameApi.ts @@ -129,7 +129,21 @@ const parsePorcelainBlame = (output: string): FileBlameResponse => { } } - return { ranges, commits }; + // Coalesce adjacent same-commit ranges. Porcelain emits a fresh group + // whenever the source-line numbering is discontinuous in the commit's + // snapshot, even when the final-file lines are contiguous and attributed + // to the same commit. + const coalescedRanges: FileBlameResponse['ranges'] = []; + for (const range of ranges) { + const last = coalescedRanges[coalescedRanges.length - 1]; + if (last && last.hash === range.hash && last.startLine + last.lineCount === range.startLine) { + last.lineCount += range.lineCount; + } else { + coalescedRanges.push({ ...range }); + } + } + + return { ranges: coalescedRanges, commits }; }; export const getFileBlame = async ({ path: filePath, repo: repoName, ref }: FileBlameRequest, { source }: { source?: string } = {}): Promise => From f9f41d07449a6b4cf94bc898d7f582f9a7e0a9a1 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 17:21:42 -0700 Subject: [PATCH 2/5] feat(web): add Code/Blame toggle and file stats to code preview panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/web/package.json | 3 +- .../codePreviewPanel/blameViewToggle.tsx | 65 ++++++++++++++ .../codePreviewPanel/codePreviewPanel.tsx | 31 +++++++ .../web/src/components/ui/toggle-group.tsx | 61 +++++++++++++ yarn.lock | 89 +++++++++++++++++-- 5 files changed, 240 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx create mode 100644 packages/web/src/components/ui/toggle-group.tsx diff --git a/packages/web/package.json b/packages/web/package.json index 78b1d68e5..9687787dc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -86,7 +86,8 @@ "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", - "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.1.4", "@react-email/components": "^1.0.2", "@react-email/render": "^2.0.0", diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx new file mode 100644 index 000000000..a8a1d863b --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useRouter } from "next/navigation"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { getBrowsePath } from "@/app/(app)/browse/hooks/utils"; + +interface BlameViewToggleProps { + repoName: string; + revisionName?: string; + path: string; + blame: boolean; +} + +export const BlameViewToggle = ({ repoName, revisionName, path, blame }: BlameViewToggleProps) => { + const router = useRouter(); + + const handleValueChange = (value: string) => { + // Radix calls onValueChange with an empty string when the user clicks + // the already-selected item (would deselect). Ignore that — we want + // exactly one of Code / Blame to always be selected. + if (!value) { + return; + } + router.push(getBrowsePath({ + repoName, + revisionName, + path, + pathType: 'blob', + blame: value === 'blame', + })); + }; + + // The Toggle "default" size is icon-sized (h-7 w-7 p-0) since it's the + // codebase's only declared size. `w-auto min-w-0 px-3` lets the items size + // to their text. The remaining classes turn the two items into a connected + // segmented control: gap-0 on the group removes the flex gap, rounded-*-none + // squares off the inner corners, and -ml-px pulls the second item over so + // its left border overlaps the first item's right border (no double seam). + const baseItemClass = "w-auto min-w-0 px-3"; + + return ( + + + Code + + + Blame + + + ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx index 294c2f0a0..6f8bbc18b 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx @@ -8,9 +8,20 @@ import { X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { getBrowsePath } from "../../../hooks/utils"; +import { BlameViewToggle } from "./blameViewToggle"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; import { getFileBlame, getFileSource } from '@/features/git'; +const formatFileSize = (bytes: number): string => { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +}; + interface CodePreviewPanelProps { path: string; repoName: string; @@ -54,6 +65,13 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe return
Error loading blame: {blameResponse.message}
} + const source = fileSourceResponse.source; + const lineCount = source.length === 0 + ? 0 + : source.split('\n').length - (source.endsWith('\n') ? 1 : 0); + const byteSize = Buffer.byteLength(source, 'utf-8'); + const fileSize = formatFileSize(byteSize); + const codeHostInfo = getCodeHostInfoForRepo({ codeHostType: repoInfoResponse.codeHostType, name: repoInfoResponse.name, @@ -98,6 +116,19 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe )}
+ {!previewRef && ( +
+ + + {lineCount.toLocaleString()} lines · {fileSize} + +
+ )} {previewRef && (
diff --git a/packages/web/src/components/ui/toggle-group.tsx b/packages/web/src/components/ui/toggle-group.tsx new file mode 100644 index 000000000..1c876bbee --- /dev/null +++ b/packages/web/src/components/ui/toggle-group.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/yarn.lock b/yarn.lock index 41dccb31a..ab8fa88cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5196,6 +5196,13 @@ __metadata: languageName: node linkType: hard +"@radix-ui/primitive@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/primitive@npm:1.1.3" + checksum: 10c0/88860165ee7066fa2c179f32ffcd3ee6d527d9dcdc0e8be85e9cb0e2c84834be8e3c1a976c74ba44b193f709544e12f54455d892b28e32f0708d89deda6b9f1d + languageName: node + linkType: hard + "@radix-ui/react-accordion@npm:^1.2.11": version: 1.2.11 resolution: "@radix-ui/react-accordion@npm:1.2.11" @@ -6108,6 +6115,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-roving-focus@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-roving-focus@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/2cd43339c36e89a3bf1db8aab34b939113dfbde56bf3a33df2d74757c78c9489b847b1962f1e2441c67e41817d120cb6177943e0f655f47bc1ff8e44fd55b1a2 + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-roving-focus@npm:1.1.2" @@ -6377,13 +6411,17 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-toggle@npm:^1.1.0": - version: 1.1.2 - resolution: "@radix-ui/react-toggle@npm:1.1.2" +"@radix-ui/react-toggle-group@npm:^1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-toggle-group@npm:1.1.11" dependencies: - "@radix-ui/primitive": "npm:1.1.1" - "@radix-ui/react-primitive": "npm:2.0.2" - "@radix-ui/react-use-controllable-state": "npm:1.1.0" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-roving-focus": "npm:1.1.11" + "@radix-ui/react-toggle": "npm:1.1.10" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" peerDependencies: "@types/react": "*" "@types/react-dom": "*" @@ -6394,7 +6432,28 @@ __metadata: optional: true "@types/react-dom": optional: true - checksum: 10c0/2cd8dc6b64c2680f4c0662ff2424963e8cc432de3a925a549e8fd5e5e7b48da1a08434ef4ab49b6b627faea1628160f89a16f098399104ed06a00220170f72a2 + checksum: 10c0/c8cbccda3e25754ed9f3145c67792df2d5d0ee1a910bde6dc07c4577ab508d4b939f145569d4e2af5b17dc4a5c701473380d8695248f8620cf0a372c05b8e958 + languageName: node + linkType: hard + +"@radix-ui/react-toggle@npm:1.1.10, @radix-ui/react-toggle@npm:^1.1.10": + version: 1.1.10 + resolution: "@radix-ui/react-toggle@npm:1.1.10" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/5406cdf5dd7299ae6cfdb4865dc5fd43ca3c475ebcd4e86830bd296d734255b61f749c9bde452ebfaad126033f92dd1112ee9d95982344ffad34491238dcc9b1 languageName: node linkType: hard @@ -6456,6 +6515,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-use-callback-ref@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/5f6aff8592dea6a7e46589808912aba3fb3b626cf6edd2b14f01638b61dbbe49eeb9f67cd5601f4c15b2fb547b9a7e825f7c4961acd4dd70176c969ae405f8d8 + languageName: node + linkType: hard + "@radix-ui/react-use-controllable-state@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1" @@ -8729,7 +8801,8 @@ __metadata: "@radix-ui/react-switch": "npm:^1.2.4" "@radix-ui/react-tabs": "npm:^1.1.2" "@radix-ui/react-toast": "npm:^1.2.2" - "@radix-ui/react-toggle": "npm:^1.1.0" + "@radix-ui/react-toggle": "npm:^1.1.10" + "@radix-ui/react-toggle-group": "npm:^1.1.11" "@radix-ui/react-tooltip": "npm:^1.1.4" "@react-email/components": "npm:^1.0.2" "@react-email/preview-server": "npm:5.2.10" From ce297013e2ed85080e70fa47cd9aa175a56cc1cd Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 17:26:24 -0700 Subject: [PATCH 3/5] fix: set activeBottomPanelTab to explore when goto refs / defs --- .../ee/features/codeNav/components/symbolHoverPopup/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx index 7e7bcc252..0a84592df 100644 --- a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx @@ -153,6 +153,7 @@ export const SymbolHoverPopup: React.FC = ({ }, activeExploreMenuTab: "definitions", isBottomPanelCollapsed: false, + activeBottomPanelTab: 'explore' } } : {}), }); @@ -196,6 +197,7 @@ export const SymbolHoverPopup: React.FC = ({ }, activeExploreMenuTab: "references", isBottomPanelCollapsed: false, + activeBottomPanelTab: 'explore' } }) }, [captureEvent, fileName, language, navigateToPath, repoName, revisionName, source, symbolInfo]); From 8e719edf54d2b0800adb87f6906220f02807e032 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 17:27:40 -0700 Subject: [PATCH 4/5] chore(web): add CHANGELOG entry for file blame view Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5d0fc48..209582e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157) - 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) +- 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) ### Changed - Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159) From 1e293ec8bccacbd67953a52f75e1c12418c6d689 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 29 Apr 2026 18:03:45 -0700 Subject: [PATCH 5/5] color gradient --- .../codePreviewPanel/blameAgeColors.ts | 46 +++++++++++++++++++ .../codePreviewPanel/blameAgeLegend.tsx | 19 ++++++++ .../codePreviewPanel/blameGutterExtension.ts | 38 ++++++++++++++- .../codePreviewPanel/codePreviewPanel.tsx | 7 +++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeColors.ts create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeLegend.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeColors.ts b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeColors.ts new file mode 100644 index 000000000..d78066c2a --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeColors.ts @@ -0,0 +1,46 @@ +// Shared color ramp for the age-of-commit indicator. Used by the blame gutter +// (left border of each cell) and the legend rendered next to the toolbar. +// +// Tailwind's JIT scanner reads class names from source, so each class must +// appear as a complete literal string. Don't try to construct these via +// template strings. + +export const BLAME_AGE_BUCKET_COUNT = 10; + +// In dark mode the ramp is flipped: pale shades (amber-50/100) are +// high-contrast against a dark background, dark shades blend in. We want +// "newer" to pop visually in both themes, so the dark-mode bucket-0 (oldest) +// is amber-900 (low contrast → fades) and dark-mode bucket-9 (newest) is +// amber-50 (high contrast → pops). The light-mode ramp stays unchanged. +export const BLAME_AGE_BG_CLASSES = [ + 'bg-slate-50 dark:bg-slate-900', + 'bg-slate-100 dark:bg-slate-800', + 'bg-slate-200 dark:bg-slate-700', + 'bg-slate-300 dark:bg-slate-600', + 'bg-slate-400 dark:bg-slate-500', + 'bg-slate-500 dark:bg-slate-400', + 'bg-slate-600 dark:bg-slate-300', + 'bg-slate-700 dark:bg-slate-200', + 'bg-slate-800 dark:bg-slate-100', + 'bg-slate-900 dark:bg-slate-50', +] as const; + +/** + * Linear time mapping: given a commit date (ISO 8601) and the file's overall + * date range, returns a bucket 0..9 (palest..darkest). Clamps out-of-range + * inputs (e.g., clock-skewed future dates) to the endpoints. + */ +export const computeAgeBucket = ( + isoDate: string, + oldestMs: number, + newestMs: number, +): number => { + const max = BLAME_AGE_BUCKET_COUNT - 1; + if (newestMs === oldestMs) { + return max; + } + const t = new Date(isoDate).getTime(); + const ratio = (t - oldestMs) / (newestMs - oldestMs); + const bucket = Math.floor(ratio * max); + return Math.max(0, Math.min(max, bucket)); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeLegend.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeLegend.tsx new file mode 100644 index 000000000..b885b368c --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameAgeLegend.tsx @@ -0,0 +1,19 @@ +import { BLAME_AGE_BG_CLASSES } from "./blameAgeColors"; +import { cn } from "@/lib/utils"; + +export const BlameAgeLegend = () => { + return ( +
+ Older +
+ {BLAME_AGE_BG_CLASSES.map((bg, i) => ( +
+ ))} +
+ Newer +
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts index 77bd75ba2..3cf74f268 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts @@ -3,6 +3,7 @@ import { EditorState, Extension, Prec, Range as CMRange, RangeSet, StateField } import { formatDistanceToNowStrict } from "date-fns"; import type { FileBlameResponse } from "@/features/git"; import { cn } from "@/lib/utils"; +import { BLAME_AGE_BG_CLASSES, computeAgeBucket } from "./blameAgeColors"; type LineEntry = { hash: string; @@ -17,6 +18,9 @@ type LineEntry = { // True for first-line cells except line 1 of the file, so the divider // border doesn't render at the very top of the gutter. showStartBorder: boolean; + // 0..9 bucket for the age-of-commit indicator stripe. Same value across + // every line of a region (continuation lines included). + ageBucket: number; }; // @see: https://lucide.dev/icons/file-stack @@ -29,11 +33,23 @@ const buildCellDom = ( onReblameClick: (previous: { hash: string; path: string }) => void, ): HTMLElement => { const cell = document.createElement('div'); + // `relative` so the absolutely-positioned age stripe inside has a + // positioning context. The stripe is a child
rather than a + // border-left because tailwind-merge collapses any same-group border-color + // class (e.g. `border-border` on the region divider) with the per-side + // amber color, dropping the stripe on first-line cells. cell.className = cn( - 'flex items-start h-full px-2 overflow-hidden text-xs text-muted-foreground', + 'relative flex items-start h-full pl-2 pr-2 overflow-hidden text-xs text-muted-foreground', entry.showStartBorder && 'border-t border-border', ); + const stripe = document.createElement('div'); + stripe.className = cn( + 'absolute inset-y-0 left-0 w-0.5', + BLAME_AGE_BG_CLASSES[entry.ageBucket], + ); + cell.appendChild(stripe); + if (entry.message === null || entry.date === null) { // Continuation line — empty cell with a non-breaking space so the row // still occupies its full line height. @@ -105,6 +121,7 @@ class BlameMarker extends GutterMarker { a.date === b.date && a.authorEmail === b.authorEmail && a.showStartBorder === b.showStartBorder && + a.ageBucket === b.ageBucket && a.previous?.hash === b.previous?.hash && a.previous?.path === b.previous?.path ); @@ -155,9 +172,26 @@ const computeActive = ( }; const buildLineIndex = (blame: FileBlameResponse): Map => { + // Compute the file's overall date range so each commit's age can be + // mapped to a 0..9 bucket. We assume blame.commits' `date` fields are + // ISO 8601 strings. + const dateMs = Object.values(blame.commits) + .map(c => new Date(c.date).getTime()) + .filter(t => Number.isFinite(t)); + const oldestMs = dateMs.length > 0 ? Math.min(...dateMs) : 0; + const newestMs = dateMs.length > 0 ? Math.max(...dateMs) : 0; + + // Per-commit bucket cache so every line of a region gets the same value + // (and we don't recompute for each line). + const bucketByHash = new Map(); + for (const [hash, commit] of Object.entries(blame.commits)) { + bucketByHash.set(hash, computeAgeBucket(commit.date, oldestMs, newestMs)); + } + const index = new Map(); for (const range of blame.ranges) { const commit = blame.commits[range.hash]; + const ageBucket = bucketByHash.get(range.hash) ?? 0; for (let i = 0; i < range.lineCount; i++) { const lineNumber = range.startLine + i; const isFirstLineOfRange = i === 0; @@ -170,6 +204,7 @@ const buildLineIndex = (blame: FileBlameResponse): Map => { authorEmail: commit.authorEmail, previous: commit.previous ?? null, showStartBorder, + ageBucket, }); } else { index.set(lineNumber, { @@ -179,6 +214,7 @@ const buildLineIndex = (blame: FileBlameResponse): Map => { authorEmail: null, previous: null, showStartBorder, + ageBucket, }); } } diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx index 6f8bbc18b..7209abdab 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx @@ -8,6 +8,7 @@ import { X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { getBrowsePath } from "../../../hooks/utils"; +import { BlameAgeLegend } from "./blameAgeLegend"; import { BlameViewToggle } from "./blameViewToggle"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; import { getFileBlame, getFileSource } from '@/features/git'; @@ -127,6 +128,12 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe {lineCount.toLocaleString()} lines · {fileSize} + {blame && ( + <> + + + + )}
)} {previewRef && (