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) 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/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 new file mode 100644 index 000000000..3cf74f268 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameGutterExtension.ts @@ -0,0 +1,292 @@ +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"; +import { BLAME_AGE_BG_CLASSES, computeAgeBucket } from "./blameAgeColors"; + +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; + // 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 +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'); + // `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( + '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. + 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.ageBucket === b.ageBucket && + 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 => { + // 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; + 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, + ageBucket, + }); + } else { + index.set(lineNumber, { + hash: range.hash, + message: null, + date: null, + authorEmail: null, + previous: null, + showStartBorder, + ageBucket, + }); + } + } + } + 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/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 bad7e67c9..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,8 +8,20 @@ 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 { getFileSource } from '@/features/git'; +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; @@ -18,18 +30,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 +62,17 @@ 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 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, @@ -84,6 +117,25 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe )}
+ {!previewRef && ( +
+ + + {lineCount.toLocaleString()} lines · {fileSize} + + {blame && ( + <> + + + + )} +
+ )} {previewRef && (
@@ -132,6 +184,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/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/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]); 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 => 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"