diff --git a/CHANGELOG.md b/CHANGELOG.md index dec8dc1..83c23a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] ### Added +- Adds completely revamped **heatmap** annotations + - The indicator's, now customizable, color will either be hot or cold based on the age of the most recent change (cold after 90 days by default) — closes [#419](https://github.com/eamodio/vscode-gitlens/issues/419) + - The indicator's brightness ranges from bright (newer) to dim (older) based on the relative age, which is calculated from the median age of all the changes in the file + - Adds `gitlens.heatmap.ageThreshold` setting to specify the age of the most recent change (in days) after which the gutter heatmap annotations will be cold rather than hot (i.e. will use `gitlens.heatmap.coldColor` instead of `gitlens.heatmap.hotColor`) + - Adds `gitlens.heatmap.coldColor` setting to specify the base color of the gutter heatmap annotations when the most recent change is older (cold) than the `gitlens.heatmap.ageThreshold` setting + - Adds `gitlens.heatmap.hotColor` setting to specify the base color of the gutter heatmap annotations when the most recent change is newer (hot) than the `gitlens.heatmap.ageThreshold` setting - Adds new branch history node under the **Repository Status** node in the *GitLens* explorer - Adds GitLab and Visual Studio Team Services icons to the remote nodes in the *GitLens* explorer — thanks to [PR #421](https://github.com/eamodio/vscode-gitlens/pull/421) by Maxim Pekurin ([@pmaxim25](https://github.com/pmaxim25)) diff --git a/README.md b/README.md index eace2d0..526c59c 100644 --- a/README.md +++ b/README.md @@ -342,8 +342,8 @@ An on-demand, [customizable](#gitlens-results-explorer-settings "Jump to the Git - Adds on-demand, [customizable](#gutter-blame-settings "Jump to the Gutter Blame settings"), and [themable](#themable-colors "Jump to the Themable Colors"), **gutter blame annotations** for the whole file - Contains the commit message and date, by [default](#gutter-blame-settings "Jump to the Gutter Blame settings") - - Adds a **heatmap** (age) indicator on right edge (by [default](#gutter-blame-settings "Jump to the Gutter Blame settings")) of the gutter to provide an easy, at-a-glance way to tell the age of a line ([optional](#gutter-blame-settings "Jump to the Gutter Blame settings"), on by default) - - Indicator ranges from bright yellow (newer) to dark brown (older) + - Adds a **heatmap** (age) indicator on right edge (by [default](#gutter-blame-settings "Jump to the Gutter Blame settings")) of the gutter to provide an easy, at-a-glance way to tell how recently lines were changed ([optional](#gutter-blame-settings "Jump to the Gutter Blame settings"), on by default) + - See the [gutter heatmap](#gutter-Heatmap "Jump to the Gutter Heatmap") section below for more details - Adds a *Toggle File Blame Annotations* command (`gitlens.toggleFileBlame`) with a shortcut of `alt+b` to toggle the blame annotations on and off - Press `Escape` to turn off the annotations @@ -353,8 +353,9 @@ An on-demand, [customizable](#gitlens-results-explorer-settings "Jump to the Git Gutter Heatmap

-- Adds an on-demand **heatmap** to the edge of the gutter to show the relative age of a line - - Indicator ranges from bright yellow (newer) to dark brown (older) +- Adds an on-demand **heatmap** to the edge of the gutter to show how recently lines were changed + - The indicator's [customizable](#gutter-heatmap-settings "Jump to the Gutter Heatmap settings") color will either be hot or cold based on the age of the most recent change (cold after 90 days by [default](#gutter-heatmap-settings "Jump to the Gutter Heatmap settings")) + - The indicator's brightness ranges from bright (newer) to dim (older) based on the relative age, which is calculated from the median age of all the changes in the file - Adds *Toggle File Heatmap Annotations* command (`gitlens.toggleFileHeatmap`) to toggle the heatmap on and off - Press `Escape` to turn off the annotations @@ -717,6 +718,9 @@ See also [Explorer Settings](#explorer-settings "Jump to the Explorer settings") |Name | Description |-----|------------ +|`gitlens.heatmap.ageThreshold`|Specifies the age of the most recent change (in days) after which the gutter heatmap annotations will be cold rather than hot (i.e. will use `gitlens.heatmap.coldColor` instead of `gitlens.heatmap.hotColor`) +|`gitlens.heatmap.coldColor`|Specifies the base color of the gutter heatmap annotations when the most recent change is older (cold) than the `gitlens.heatmap.ageThreshold` setting +|`gitlens.heatmap.hotColor`|Specifies the base color of the gutter heatmap annotations when the most recent change is newer (hot) than the `gitlens.heatmap.ageThreshold` setting |`gitlens.heatmap.toggleMode`|Specifies how the gutter heatmap annotations will be toggled
`file` - toggle each file individually
`window` - toggle the window, i.e. all files at once ### Hover Settings diff --git a/images/ss-heatmap.png b/images/ss-heatmap.png index 7e7fc18..3227a22 100644 Binary files a/images/ss-heatmap.png and b/images/ss-heatmap.png differ diff --git a/package.json b/package.json index 107d9e8..6982d4c 100644 --- a/package.json +++ b/package.json @@ -500,6 +500,24 @@ "description": "Specifies the starting view of the `GitLens` explorer\n `auto` - shows the last selected view, defaults to `repository`\n `history` - shows the commit history of the current file\n `repository` - shows a repository explorer", "scope": "window" }, + "gitlens.heatmap.ageThreshold": { + "type": "string", + "default": "90", + "description": "Specifies the age of the most recent change (in days) after which the gutter heatmap annotations will be cold rather than hot (i.e. will use `gitlens.heatmap.coldColor` instead of `gitlens.heatmap.hotColor`)", + "scope": "window" + }, + "gitlens.heatmap.coldColor": { + "type": "string", + "default": "#0a60f6", + "description": "Specifies the base color of the gutter heatmap annotations when the most recent change is older (cold) than the `gitlens.heatmap.ageThreshold` setting", + "scope": "window" + }, + "gitlens.heatmap.hotColor": { + "type": "string", + "default": "#f66a0a", + "description": "Specifies the base color of the gutter heatmap annotations when the most recent change is newer (hot) than the `gitlens.heatmap.ageThreshold` setting", + "scope": "window" + }, "gitlens.heatmap.toggleMode": { "type": "string", "default": "file", diff --git a/src/annotations/annotations.ts b/src/annotations/annotations.ts index 754e765..e9100c3 100644 --- a/src/annotations/annotations.ts +++ b/src/annotations/annotations.ts @@ -1,10 +1,20 @@ -import { Dates, Objects, Strings } from '../system'; +import { Objects, Strings } from '../system'; import { DecorationInstanceRenderOptions, DecorationOptions, MarkdownString, ThemableDecorationRenderOptions, ThemeColor } from 'vscode'; import { DiffWithCommand, OpenCommitInRemoteCommand, OpenFileRevisionCommand, ShowQuickCommitDetailsCommand, ShowQuickCommitFileDetailsCommand } from '../commands'; import { FileAnnotationType } from './../configuration'; import { GlyphChars } from '../constants'; import { Container } from '../container'; import { CommitFormatter, GitCommit, GitDiffChunkLine, GitRemote, GitService, GitUri, ICommitFormatOptions } from '../gitService'; +import { toRgba } from '../ui/shared/colors'; + +export interface ComputedHeatmap { + cold: boolean; + colors: { hot: string, cold: string }; + median: number; + newest: number; + oldest: number; + computeAge(date: Date): number; +} interface IHeatmapConfig { enabled: boolean; @@ -16,29 +26,45 @@ interface IRenderOptions extends DecorationInstanceRenderOptions, ThemableDecora uncommittedColor?: string | ThemeColor; } +const defaultHeatmapHotColor = '#f66a0a'; +const defaultHeatmapColdColor = '#0a60f6'; const escapeMarkdownRegEx = /[`\>\#\*\_\-\+\.]/g; // const sampleMarkdown = '## message `not code` *not important* _no underline_ \n> don\'t quote me \n- don\'t list me \n+ don\'t list me \n1. don\'t list me \nnot h1 \n=== \nnot h2 \n---\n***\n---\n___'; +let computedHeatmapColor: { + color: string, + rgb: string +}; + export class Annotations { - static applyHeatmap(decoration: DecorationOptions, date: Date, now: number) { - const color = this.getHeatmapColor(now, date); + static applyHeatmap(decoration: DecorationOptions, date: Date, heatmap: ComputedHeatmap) { + const color = this.getHeatmapColor(date, heatmap); (decoration.renderOptions!.before! as any).borderColor = color; } - private static getHeatmapColor(now: number, date: Date) { - const days = Dates.dateDaysFromNow(date, now); - - if (days <= 2) return '#ffeca7'; - if (days <= 7) return '#ffdd8c'; - if (days <= 14) return '#ffdd7c'; - if (days <= 30) return '#fba447'; - if (days <= 60) return '#f68736'; - if (days <= 90) return '#f37636'; - if (days <= 180) return '#ca6632'; - if (days <= 365) return '#c0513f'; - if (days <= 730) return '#a2503a'; - return '#793738'; + private static getHeatmapColor(date: Date, heatmap: ComputedHeatmap) { + const baseColor = heatmap.cold + ? heatmap.colors.cold + : heatmap.colors.hot; + + const age = heatmap.computeAge(date); + if (age === 0) return baseColor; + + if (computedHeatmapColor === undefined || computedHeatmapColor.color !== baseColor) { + let rgba = toRgba(baseColor); + if (rgba == null) { + rgba = toRgba(heatmap.cold ? defaultHeatmapColdColor : defaultHeatmapHotColor)!; + } + + const [r, g, b] = rgba; + computedHeatmapColor = { + color: baseColor, + rgb: `${r}, ${g}, ${b}` + }; + } + + return `rgba(${computedHeatmapColor.rgb}, ${(1 - (age / 10)).toFixed(2)})`; } private static getHoverCommandBar(commit: GitCommit, hasRemote: boolean, annotationType?: FileAnnotationType, line: number = 0) { @@ -213,14 +239,14 @@ export class Annotations { } as IRenderOptions; } - static heatmap(commit: GitCommit, now: number, renderOptions: IRenderOptions): DecorationOptions { + static heatmap(commit: GitCommit, heatmap: ComputedHeatmap, renderOptions: IRenderOptions): DecorationOptions { const decoration = { renderOptions: { before: { ...renderOptions } } as DecorationInstanceRenderOptions } as DecorationOptions; - Annotations.applyHeatmap(decoration, commit.date, now); + Annotations.applyHeatmap(decoration, commit.date, heatmap); return decoration; } diff --git a/src/annotations/blameAnnotationProvider.ts b/src/annotations/blameAnnotationProvider.ts index 97dbf0a..852389e 100644 --- a/src/annotations/blameAnnotationProvider.ts +++ b/src/annotations/blameAnnotationProvider.ts @@ -2,7 +2,7 @@ import { Arrays, Iterables } from '../system'; import { CancellationToken, Disposable, Hover, HoverProvider, languages, Position, Range, TextDocument, TextEditor, TextEditorDecorationType } from 'vscode'; import { AnnotationProviderBase } from './annotationProvider'; -import { Annotations } from './annotations'; +import { Annotations, ComputedHeatmap } from './annotations'; import { Container } from '../container'; import { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; import { GitBlame, GitCommit, GitUri } from '../gitService'; @@ -91,6 +91,69 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase return blame; } + protected getComputedHeatmap(blame: GitBlame): ComputedHeatmap { + const dates = []; + + let commit; + let previousSha; + for (const l of blame.lines) { + if (previousSha === l.sha) continue; + previousSha = l.sha; + + commit = blame.commits.get(l.sha); + if (commit === undefined) continue; + + dates.push(commit.date); + } + + dates.sort((a, b) => a.getTime() - b.getTime()); + + const half = Math.floor(dates.length / 2); + const median = dates.length % 2 + ? dates[half].getTime() + : (dates[half - 1].getTime() + dates[half].getTime()) / 2.0; + + const lookup: number[] = []; + + const newest = dates[dates.length - 1].getTime(); + let step = (newest - median) / 5; + for (let i = 5; i > 0; i--) { + lookup.push(median + (step * i)); + } + + lookup.push(median); + + const oldest = dates[0].getTime(); + step = (median - oldest) / 4; + for (let i = 1; i <= 4; i++) { + lookup.push(median - (step * i)); + } + + const d = new Date(); + d.setDate(d.getDate() - (Container.config.heatmap.ageThreshold || 90)); + + return { + cold: newest < d.getTime(), + colors: { + cold: Container.config.heatmap.coldColor, + hot: Container.config.heatmap.hotColor + }, + median: median, + newest: newest, + oldest: oldest, + computeAge: (date: Date) => { + const time = date.getTime(); + let index = 0; + for (let i = 0; i < lookup.length; i++) { + index = i; + if (time >= lookup[i]) break; + } + + return index; + } + }; + } + registerHoverProviders(providers: { details: boolean, changes: boolean }) { if (!Container.config.hovers.enabled || !Container.config.hovers.annotations.enabled || (!providers.details && !providers.changes)) return; diff --git a/src/annotations/fileAnnotationController.ts b/src/annotations/fileAnnotationController.ts index 23a0e26..36cf8a5 100644 --- a/src/annotations/fileAnnotationController.ts +++ b/src/annotations/fileAnnotationController.ts @@ -161,6 +161,7 @@ export class FileAnnotationController extends Disposable { if (configuration.changed(e, configuration.name('blame').value) || configuration.changed(e, configuration.name('recentChanges').value) || + configuration.changed(e, configuration.name('heatmap').value) || configuration.changed(e, configuration.name('hovers').value)) { // Since the configuration has changed -- reset any visible annotations for (const provider of this._annotationProviders.values()) { @@ -416,7 +417,7 @@ export class FileAnnotationController extends Disposable { this._keyboardScope = undefined; } - private async showAnnotationsCore(currentProvider: AnnotationProviderBase | undefined, editor: TextEditor, type: FileAnnotationType, shaOrLine?: string | number, progress?: Progress<{ message: string}>): Promise { + private async showAnnotationsCore(currentProvider: AnnotationProviderBase | undefined, editor: TextEditor, type: FileAnnotationType, shaOrLine?: string | number, progress?: Progress<{ message: string }>): Promise { if (progress !== undefined) { let annotationsLabel = 'annotations'; switch (type) { diff --git a/src/annotations/gutterBlameAnnotationProvider.ts b/src/annotations/gutterBlameAnnotationProvider.ts index 005d2fd..96e9e05 100644 --- a/src/annotations/gutterBlameAnnotationProvider.ts +++ b/src/annotations/gutterBlameAnnotationProvider.ts @@ -33,7 +33,6 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { tokenOptions: tokenOptions }; - const now = Date.now(); const avatars = cfg.avatars; const gravatarDefault = Container.config.defaultGravatarsStyle; const separateLines = cfg.separateLines; @@ -48,6 +47,11 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { let gutter: DecorationOptions | undefined; let previousSha: string | undefined; + let computedHeatmap; + if (cfg.heatmap.enabled) { + computedHeatmap = this.getComputedHeatmap(blame); + } + for (const l of blame.lines) { const line = l.line; @@ -106,8 +110,8 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { gutter = Annotations.gutter(commit, cfg.format, options, renderOptions); - if (cfg.heatmap.enabled) { - Annotations.applyHeatmap(gutter, commit.date, now); + if (computedHeatmap !== undefined) { + Annotations.applyHeatmap(gutter, commit.date, computedHeatmap); } gutter.range = new Range(line, 0, line, 0); diff --git a/src/annotations/heatmapBlameAnnotationProvider.ts b/src/annotations/heatmapBlameAnnotationProvider.ts index 86a1250..c5f6330 100644 --- a/src/annotations/heatmapBlameAnnotationProvider.ts +++ b/src/annotations/heatmapBlameAnnotationProvider.ts @@ -17,7 +17,6 @@ export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase const start = process.hrtime(); - const now = Date.now(); const renderOptions = Annotations.heatmapRenderOptions(); this.decorations = []; @@ -26,6 +25,8 @@ export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase let commit: GitBlameCommit | undefined; let heatmap: DecorationOptions | undefined; + const computedHeatmap = this.getComputedHeatmap(blame); + for (const l of blame.lines) { const line = l.line; @@ -44,7 +45,7 @@ export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase commit = blame.commits.get(l.sha); if (commit === undefined) continue; - heatmap = Annotations.heatmap(commit, now, renderOptions); + heatmap = Annotations.heatmap(commit, computedHeatmap, renderOptions); heatmap.range = new Range(line, 0, line, 0); this.decorations.push(heatmap); @@ -62,4 +63,4 @@ export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase this.selection(shaOrLine, blame); return true; } -} \ No newline at end of file +} diff --git a/src/ui/config.ts b/src/ui/config.ts index 37f4b50..992ed57 100644 --- a/src/ui/config.ts +++ b/src/ui/config.ts @@ -299,6 +299,9 @@ export interface IConfig { gitExplorer: IGitExplorerConfig; heatmap: { + ageThreshold: number; + coldColor: string; + hotColor: string; toggleMode: AnnotationsToggleMode; }; diff --git a/src/ui/images/settings/heatmap.png b/src/ui/images/settings/heatmap.png index 2f1cdb1..b57c4af 100644 Binary files a/src/ui/images/settings/heatmap.png and b/src/ui/images/settings/heatmap.png differ diff --git a/src/ui/settings/index.html b/src/ui/settings/index.html index 85678ce..7b1290f 100644 --- a/src/ui/settings/index.html +++ b/src/ui/settings/index.html @@ -2,7 +2,7 @@ - + @@ -12,7 +12,7 @@
- + @@ -97,13 +98,13 @@