web: Switch to absolute blame recency calculation (#61661)

In a nutshell:

- We currently compute the recency color relative to either one year ago, three years ago, or the first commit of the repository.
- That means the colors have different meaning across different repositories.
- Fetching the date of the first commit can be expensive/slow.

This commit changes the method to calculate the color to always use the same time frame. The age of the commit is mapped to a number between [0, 1) and d3-scale-chromatic is used to
map that value to a color.
We can control the sensibility of the colors for newer/older commits by changing how we do the mapping. In this specific version I chose the midpoint to be ~ 6 months in the past.

I don't think there is "the right way" to perform the mapping but intuitively I would say that being able to better differentiate more recent commits is more useful than differentiating older commits.
This commit is contained in:
Felix Kling 2024-04-06 01:43:12 +02:00 committed by GitHub
parent fedc9a2eb1
commit ba9e78eae4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 78 additions and 98 deletions

View File

@ -1776,6 +1776,7 @@ ts_project(
"//:node_modules/@types/bloomfilter",
"//:node_modules/@types/chrome", #keep
"//:node_modules/@types/classnames",
"//:node_modules/@types/d3-scale-chromatic",
"//:node_modules/@types/d3-time-format",
"//:node_modules/@types/history",
"//:node_modules/@types/js-cookie",
@ -1801,6 +1802,7 @@ ts_project(
"//:node_modules/bloomfilter",
"//:node_modules/classnames",
"//:node_modules/copy-to-clipboard",
"//:node_modules/d3-scale-chromatic",
"//:node_modules/d3-time-format",
"//:node_modules/date-fns",
"//:node_modules/fast-json-stable-stringify",

View File

@ -12,7 +12,7 @@ import { makeRepoGitURI } from '@sourcegraph/shared/src/util/url'
import { useObservable } from '@sourcegraph/wildcard'
import { requestGraphQL } from '../../backend/graphql'
import type { ExternalServiceKind, FirstCommitDateResult, FirstCommitDateVariables } from '../../graphql-operations'
import type { ExternalRepoURLsResult, ExternalRepoURLsVariables, ExternalServiceKind } from '../../graphql-operations'
import { useBlameVisibility } from './useBlameVisibility'
@ -61,7 +61,6 @@ export interface BlameHunk {
export interface BlameHunkData {
current: BlameHunk[] | undefined
externalURLs: { url: string; serviceKind: ExternalServiceKind | null }[] | undefined
firstCommitDate: Date | undefined
}
interface RawStreamHunk {
@ -110,10 +109,8 @@ const fetchBlameViaStreaming = memoizeObservable(
revision: string
filePath: string
sourcegraphURL: string
}): Observable<BlameHunkData | ErrorLike> =>
new Observable<BlameHunkData | ErrorLike>(subscriber => {
let didEmitFirstCommitDate = false
let firstCommitDate: Date | undefined
}): Observable<BlameHunkData> =>
new Observable<BlameHunkData>(subscriber => {
let externalURLs: BlameHunkData['externalURLs']
const assembledHunks: BlameHunk[] = []
@ -121,7 +118,6 @@ const fetchBlameViaStreaming = memoizeObservable(
Promise.all([
fetchRepositoryData(repoName).then(res => {
firstCommitDate = res.firstCommitDate
externalURLs = res.externalURLs
}),
fetchEventSource(`/.api/blame${repoAndRevisionPath}/stream/${filePath}`, {
@ -168,10 +164,7 @@ const fetchBlameViaStreaming = memoizeObservable(
}
assembledHunks.push(addDisplayInfoForHunk(hunk, sourcegraphURL))
}
if (firstCommitDate !== undefined) {
didEmitFirstCommitDate = true
}
subscriber.next({ current: assembledHunks, externalURLs, firstCommitDate })
subscriber.next({ current: assembledHunks, externalURLs })
}
},
onerror(event) {
@ -182,11 +175,6 @@ const fetchBlameViaStreaming = memoizeObservable(
}),
]).then(
() => {
// This case can happen when the event source yields before the commit date is resolved
if (!didEmitFirstCommitDate) {
subscriber.next({ current: assembledHunks, externalURLs, firstCommitDate })
}
subscriber.complete()
},
error => subscriber.error(error)
@ -202,15 +190,10 @@ const fetchBlameViaStreaming = memoizeObservable(
async function fetchRepositoryData(repoName: string): Promise<Omit<BlameHunkData, 'current'>> {
return lastValueFrom(
requestGraphQL<FirstCommitDateResult, FirstCommitDateVariables>(
requestGraphQL<ExternalRepoURLsResult, ExternalRepoURLsVariables>(
gql`
query FirstCommitDate($repo: String!) {
query ExternalRepoURLs($repo: String!) {
repository(name: $repo) {
firstEverCommit {
author {
date
}
}
externalURLs {
url
serviceKind
@ -221,13 +204,9 @@ async function fetchRepositoryData(repoName: string): Promise<Omit<BlameHunkData
{ repo: repoName }
).pipe(
map(dataOrThrowErrors),
map(({ repository }) => {
const firstCommitDate = repository?.firstEverCommit?.author?.date
return {
externalURLs: repository?.externalURLs,
firstCommitDate: firstCommitDate ? new Date(firstCommitDate) : undefined,
}
})
map(({ repository }) => ({
externalURLs: repository?.externalURLs,
}))
)
)
}
@ -286,12 +265,12 @@ export const useBlameHunks = (
() =>
shouldFetchBlame
? fetchBlameViaStreaming({ revision, repoName, filePath, sourcegraphURL })
: of({ current: undefined, externalURLs: undefined, firstCommitDate: undefined }),
: of({ current: undefined, externalURLs: undefined }),
[shouldFetchBlame, revision, repoName, filePath, sourcegraphURL]
)
)
return hunks || { current: undefined, externalURLs: undefined, firstCommitDate: undefined }
return hunks || { current: undefined, externalURLs: undefined }
}
const ONE_MONTH = 30 * 24 * 60 * 60 * 1000

View File

@ -1,42 +1,44 @@
import { subYears } from 'date-fns'
import { interpolatePurples } from 'd3-scale-chromatic'
// We use an exponential scale to get more diverse colors for more recent changes.
//
// The values are sampled from the following function:
// y=0.005*1.7^x
const STEPS = [0.008, 0.0144, 0.0245, 0.0417, 0.0709, 0.1206, 0.2051, 0.3487, 0.5929, 1]
// MIDPOINT is the duration for which the scale function returns the midpoint color (0.5)
// The closer the midpoint is to "now", the more pronounced the color difference for
// recent commits. I.e. more recent commits will be easier to distinguish from each other
// than older commits.
// Conversely, if the midpoint is further back in time, the color difference for recent
// commits is less pronounced.
// Another factor is the granularity of time. If the scale is in hours, the color difference
// for commits from the last few days is more pronounced than if the scale is in months.
const COLORS = [
'var(--blame-recency-0)',
'var(--blame-recency-1)',
'var(--blame-recency-2)',
'var(--blame-recency-3)',
'var(--blame-recency-4)',
'var(--blame-recency-5)',
'var(--blame-recency-6)',
'var(--blame-recency-7)',
'var(--blame-recency-8)',
'var(--blame-recency-9)',
]
const ONE_YEAR_AGO = subYears(Date.now(), 1).getTime()
const THREE_YEARS_AGO = subYears(Date.now(), 3).getTime()
const MIDPOINT = 6 * 30 * 24 // 6 months in hours
const MILLIS_IN_HOUR = 1000 * 60 * 60
export function getBlameRecencyColor(commit: Date | undefined, firstCommitDate: Date | undefined): string {
if (!commit) {
return COLORS[0]
/**
* Get the color for the recency of a commit. The color is interpolated between
* light grey and purple, with grey being the oldest and purple being the most recent.
* The "direction" of color can be reversed, so that the most recent commits are light
* and the oldest are dark (e.g. for dark mode).
*
* @param commitDate The date of the commit
* @param lightToDark If true, the most recent commits will be light and the oldest dark. Default is false.
* @returns The color for the recency of the commit. It's an `rgb(...)` CSS color string.
*/
export function getBlameRecencyColor(commitDate: Date | undefined, lightToDark = false): string {
if (!commitDate) {
return 'var(--gray-04)'
}
// We create a recency range depending on the repo creation date. If the
// repo is newer than a year, we use the last year so that we don't have a
// scale that is too sensible.
const now = Date.now()
const start = Math.min(firstCommitDate ? firstCommitDate.getTime() : THREE_YEARS_AGO, ONE_YEAR_AGO)
const age = (Date.now() - commitDate.getTime()) / MILLIS_IN_HOUR
// Get a value between [0, 1] that represents the recency of the commit in a linear scale
const recency = Math.min(Math.max((now - commit.getTime()) / (now - start), 0), 1)
// Get a value between [0, 1) that represents the recency of the commit
// (0 is most recent, 1 is least recent)
let recency = age / (age + MIDPOINT)
// Map from the linear scale to the exponential scale
const index = STEPS.findIndex(step => recency <= step)
// The color scheme goes from light (0) to dark (1), but unless lightToDark is true,
// we want the most recent commits to be dark and the oldest to be light. Therefore
// we simply invert the recency value.
if (!lightToDark) {
recency = 1 - recency
}
return COLORS[index]
return interpolatePurples(recency)
}

View File

@ -28,7 +28,7 @@ import { getBlameRecencyColor } from '../blameRecency'
* Unlike {@link BlameHunkData} which is a list of unordered hunks, this
* structure provides a line number -> blame hunk index for fast access.
*/
interface IndexedBlameHunkData extends Pick<BlameHunkData, 'firstCommitDate' | 'externalURLs'> {
interface IndexedBlameHunkData extends Pick<BlameHunkData, 'externalURLs'> {
lines: BlameHunk[]
}
@ -245,7 +245,6 @@ const blameDataFacet = Facet.define<BlameHunkData, IndexedBlameHunkData>({
}
return {
lines,
firstCommitDate: value.firstCommitDate,
externalURLs: value.externalURLs,
}
},
@ -253,7 +252,7 @@ const blameDataFacet = Facet.define<BlameHunkData, IndexedBlameHunkData>({
class RecencyMarker extends GutterMarker {
// hunk can be undefined if when the data is not available yet
constructor(private line: number, private hunk?: BlameHunk) {
constructor(private line: number, private hunk?: BlameHunk, private darkTheme?: boolean | null | undefined) {
super()
}
@ -261,18 +260,17 @@ class RecencyMarker extends GutterMarker {
// Only consider two markers with the same line equal if
// hunk data is available. Otherwise the marker won't be
// update/recreated as new data becomes available.
return this.line === other.line && !!this.hunk && !!other.hunk
return this.line === other.line && this.darkTheme === other.darkTheme && !!this.hunk && !!other.hunk
}
public toDOM(view: EditorView): Node {
public toDOM(_view: EditorView): Node {
const dom = document.createElement('div')
dom.className = 'sg-recency-marker'
const { firstCommitDate } = view.state.facet(blameDataFacet)
if (this.hunk) {
if (this.hunk.startLine === this.line) {
dom.classList.add('border-top')
}
dom.style.backgroundColor = getBlameRecencyColor(new Date(this.hunk.author.date), firstCommitDate)
dom.style.backgroundColor = getBlameRecencyColor(new Date(this.hunk.author.date), !this.darkTheme)
}
return dom
}
@ -288,10 +286,13 @@ const blameGutter: Extension = [
lineMarker(view, line) {
const lineNumber = view.state.doc.lineAt(line.from).number
const hunks = view.state.facet(blameDataFacet).lines
return new RecencyMarker(lineNumber, hunks[lineNumber])
return new RecencyMarker(lineNumber, hunks[lineNumber], view.state.facet(EditorView.darkTheme))
},
lineMarkerChange(update) {
return update.state.facet(blameDataFacet) !== update.startState.facet(blameDataFacet)
return (
update.state.facet(blameDataFacet) !== update.startState.facet(blameDataFacet) ||
update.state.facet(EditorView.darkTheme) !== update.startState.facet(EditorView.darkTheme)
)
},
}),

View File

@ -308,18 +308,6 @@ $violet-09: #5f3dc4;
--chroma-generic-traceback-fg: #aa0000;
--chroma-text-whitespace-fg: #bbbbbb;
// Blame colors
--blame-recency-0: var(--oc-violet-9);
--blame-recency-1: var(--oc-violet-8);
--blame-recency-2: var(--oc-violet-7);
--blame-recency-3: var(--oc-violet-6);
--blame-recency-4: var(--oc-violet-5);
--blame-recency-5: var(--oc-violet-4);
--blame-recency-6: var(--oc-violet-3);
--blame-recency-7: var(--oc-violet-2);
--blame-recency-8: var(--oc-violet-1);
--blame-recency-9: var(--oc-violet-0);
// Header colors
--result-header-bg: #f5f8fa;
@ -494,18 +482,6 @@ $violet-09: #5f3dc4;
--chroma-generic-traceback-fg: #ff7b72;
--chroma-text-whitespace-fg: #6e7681;
// Blame colors
--blame-recency-0: var(--oc-violet-0);
--blame-recency-1: var(--oc-violet-1);
--blame-recency-2: var(--oc-violet-2);
--blame-recency-3: var(--oc-violet-3);
--blame-recency-4: var(--oc-violet-4);
--blame-recency-5: var(--oc-violet-5);
--blame-recency-6: var(--oc-violet-6);
--blame-recency-7: var(--oc-violet-7);
--blame-recency-8: var(--oc-violet-8);
--blame-recency-9: var(--oc-violet-9);
// Header colors
--result-header-bg: #22283b;

View File

@ -123,6 +123,7 @@
"@types/connect-history-api-fallback": "1.3.4",
"@types/d3-format": "2.0.0",
"@types/d3-scale": "^3.3.0",
"@types/d3-scale-chromatic": "^3.0.3",
"@types/d3-shape": "^1.3.1",
"@types/d3-time-format": "3.0.0",
"@types/d3-voronoi": "^1.1.9",
@ -335,6 +336,7 @@
"core-js": "^3.8.2",
"d3-format": "^2.0.0",
"d3-scale": "^3.3.0",
"d3-scale-chromatic": "^3.0.0",
"d3-shape": "^1.2.0",
"d3-time-format": "^3.0.0",
"d3-voronoi": "^1.1.2",

View File

@ -211,6 +211,9 @@ importers:
d3-scale:
specifier: ^3.3.0
version: 3.3.0
d3-scale-chromatic:
specifier: ^3.0.0
version: 3.0.0
d3-shape:
specifier: ^1.2.0
version: 1.3.7
@ -653,6 +656,9 @@ importers:
'@types/d3-scale':
specifier: ^3.3.0
version: 3.3.2
'@types/d3-scale-chromatic':
specifier: ^3.0.3
version: 3.0.3
'@types/d3-shape':
specifier: ^1.3.1
version: 1.3.2
@ -10776,6 +10782,10 @@ packages:
/@types/d3-path@1.0.9:
resolution: {integrity: sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==}
/@types/d3-scale-chromatic@3.0.3:
resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==}
dev: true
/@types/d3-scale@3.3.2:
resolution: {integrity: sha512-gGqr7x1ost9px3FvIfUMi5XA/F/yAf4UkUDtdQhpH92XCT0Oa7zkkRzY61gPVJq+DxpHn/btouw5ohWkbBsCzQ==}
dependencies:
@ -14281,6 +14291,14 @@ packages:
resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
dev: false
/d3-scale-chromatic@3.0.0:
resolution: {integrity: sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==}
engines: {node: '>=12'}
dependencies:
d3-color: 1.2.3
d3-interpolate: 1.4.0
dev: false
/d3-scale@2.2.2:
resolution: {integrity: sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==}
dependencies: