Skip to content

Commit 6d6cfdc

Browse files
authored
Git - add git blame editor decoration hover provider (#237102)
* Initial implementation * Refactor editor decoration type
1 parent a3261ea commit 6d6cfdc

File tree

1 file changed

+145
-45
lines changed

1 file changed

+145
-45
lines changed

extensions/git/src/blame.ts

+145-45
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { DecorationOptions, l10n, Position, Range, TextEditor, TextEditorChange, TextEditorDecorationType, TextEditorChangeKind, ThemeColor, Uri, window, workspace, EventEmitter, ConfigurationChangeEvent, StatusBarItem, StatusBarAlignment, Command, MarkdownString } from 'vscode';
6+
import { DecorationOptions, l10n, Position, Range, TextEditor, TextEditorChange, TextEditorDecorationType, TextEditorChangeKind, ThemeColor, Uri, window, workspace, EventEmitter, ConfigurationChangeEvent, StatusBarItem, StatusBarAlignment, Command, MarkdownString, languages, HoverProvider, CancellationToken, Hover, TextDocument } from 'vscode';
77
import { Model } from './model';
88
import { dispose, fromNow, IDisposable } from './util';
99
import { Repository } from './repository';
1010
import { throttle } from './decorators';
11-
import { BlameInformation } from './git';
11+
import { BlameInformation, Commit } from './git';
1212
import { fromGitUri, isGitUri } from './uri';
1313
import { emojify, ensureEmojis } from './emoji';
1414
import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation } from './staging';
@@ -55,6 +55,15 @@ function mapModifiedLineNumberToOriginalLineNumber(lineNumber: number, changes:
5555
return lineNumber;
5656
}
5757

58+
function getEditorDecorationRange(lineNumber: number): Range {
59+
const position = new Position(lineNumber, Number.MAX_SAFE_INTEGER);
60+
return new Range(position, position);
61+
}
62+
63+
function isBlameInformation(object: any): object is BlameInformation {
64+
return Array.isArray((object as BlameInformation).ranges);
65+
}
66+
5867
type BlameInformationTemplateTokens = {
5968
readonly hash: string;
6069
readonly hashShort: string;
@@ -191,32 +200,63 @@ export class GitBlameController {
191200
});
192201
}
193202

194-
getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation | string): MarkdownString {
195-
if (typeof blameInformation === 'string') {
196-
return new MarkdownString(blameInformation, true);
203+
async getBlameInformationDetailedHover(documentUri: Uri, blameInformation: BlameInformation): Promise<MarkdownString | undefined> {
204+
const repository = this._model.getRepository(documentUri);
205+
if (!repository) {
206+
return this.getBlameInformationHover(documentUri, blameInformation);
207+
}
208+
209+
try {
210+
const commit = await repository.getCommit(blameInformation.hash);
211+
return this.getBlameInformationHover(documentUri, commit);
212+
} catch {
213+
return this.getBlameInformationHover(documentUri, blameInformation);
197214
}
215+
}
198216

217+
getBlameInformationHover(documentUri: Uri, blameInformationOrCommit: BlameInformation | Commit): MarkdownString {
199218
const markdownString = new MarkdownString();
200-
markdownString.supportThemeIcons = true;
201219
markdownString.isTrusted = true;
220+
markdownString.supportHtml = true;
221+
markdownString.supportThemeIcons = true;
202222

203-
if (blameInformation.authorName) {
204-
markdownString.appendMarkdown(`$(account) **${blameInformation.authorName}**`);
223+
if (blameInformationOrCommit.authorName) {
224+
markdownString.appendMarkdown(`$(account) **${blameInformationOrCommit.authorName}**`);
205225

206-
if (blameInformation.authorDate) {
207-
const dateString = new Date(blameInformation.authorDate).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
208-
markdownString.appendMarkdown(`, $(history) ${fromNow(blameInformation.authorDate, true, true)} (${dateString})`);
226+
if (blameInformationOrCommit.authorDate) {
227+
const dateString = new Date(blameInformationOrCommit.authorDate).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
228+
markdownString.appendMarkdown(`, $(history) ${fromNow(blameInformationOrCommit.authorDate, true, true)} (${dateString})`);
209229
}
210230

211231
markdownString.appendMarkdown('\n\n');
212232
}
213233

214-
markdownString.appendMarkdown(`${emojify(blameInformation.subject ?? '')}\n\n`);
234+
markdownString.appendMarkdown(`${emojify(isBlameInformation(blameInformationOrCommit) ? blameInformationOrCommit.subject ?? '' : blameInformationOrCommit.message)}\n\n`);
215235
markdownString.appendMarkdown(`---\n\n`);
216236

217-
markdownString.appendMarkdown(`[$(eye) View Commit](command:git.blameStatusBarItem.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, blameInformation.hash]))} "${l10n.t('View Commit')}")`);
237+
if (!isBlameInformation(blameInformationOrCommit) && blameInformationOrCommit.shortStat) {
238+
markdownString.appendMarkdown(`<span>${blameInformationOrCommit.shortStat.files === 1 ?
239+
l10n.t('{0} file changed', blameInformationOrCommit.shortStat.files) :
240+
l10n.t('{0} files changed', blameInformationOrCommit.shortStat.files)}</span>`);
241+
242+
if (blameInformationOrCommit.shortStat.insertions) {
243+
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${blameInformationOrCommit.shortStat.insertions === 1 ?
244+
l10n.t('{0} insertion{1}', blameInformationOrCommit.shortStat.insertions, '(+)') :
245+
l10n.t('{0} insertions{1}', blameInformationOrCommit.shortStat.insertions, '(+)')}</span>`);
246+
}
247+
248+
if (blameInformationOrCommit.shortStat.deletions) {
249+
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${blameInformationOrCommit.shortStat.deletions === 1 ?
250+
l10n.t('{0} deletion{1}', blameInformationOrCommit.shortStat.deletions, '(-)') :
251+
l10n.t('{0} deletions{1}', blameInformationOrCommit.shortStat.deletions, '(-)')}</span>`);
252+
}
253+
254+
markdownString.appendMarkdown(`\n\n---\n\n`);
255+
}
256+
257+
markdownString.appendMarkdown(`[$(eye) View Commit](command:git.blameStatusBarItem.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, blameInformationOrCommit.hash]))} "${l10n.t('View Commit')}")`);
218258
markdownString.appendMarkdown('&nbsp;&nbsp;&nbsp;&nbsp;');
219-
markdownString.appendMarkdown(`[$(copy) ${blameInformation.hash.substring(0, 8)}](command:git.blameStatusBarItem.copyContent?${encodeURIComponent(JSON.stringify(blameInformation.hash))} "${l10n.t('Copy Commit Hash')}")`);
259+
markdownString.appendMarkdown(`[$(copy) ${blameInformationOrCommit.hash.substring(0, 8)}](command:git.blameStatusBarItem.copyContent?${encodeURIComponent(JSON.stringify(blameInformationOrCommit.hash))} "${l10n.t('Copy Commit Hash')}")`);
220260

221261
return markdownString;
222262
}
@@ -411,36 +451,81 @@ export class GitBlameController {
411451
}
412452
}
413453

414-
class GitBlameEditorDecoration {
415-
private readonly _decorationType: TextEditorDecorationType;
454+
class GitBlameEditorDecoration implements HoverProvider {
455+
private _decoration: TextEditorDecorationType | undefined;
456+
private get decoration(): TextEditorDecorationType {
457+
if (!this._decoration) {
458+
this._decoration = window.createTextEditorDecorationType({
459+
after: {
460+
color: new ThemeColor('git.blame.editorDecorationForeground')
461+
}
462+
});
463+
}
464+
465+
return this._decoration;
466+
}
467+
468+
private _hoverDisposable: IDisposable | undefined;
416469
private _disposables: IDisposable[] = [];
417470

418471
constructor(private readonly _controller: GitBlameController) {
419-
this._decorationType = window.createTextEditorDecorationType({
420-
after: {
421-
color: new ThemeColor('git.blame.editorDecorationForeground')
422-
}
423-
});
424-
this._disposables.push(this._decorationType);
425-
426472
workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables);
427473
window.onDidChangeActiveTextEditor(this._onDidChangeActiveTextEditor, this, this._disposables);
428-
429474
this._controller.onDidChangeBlameInformation(e => this._updateDecorations(e), this, this._disposables);
475+
476+
this._onDidChangeConfiguration();
430477
}
431478

432-
private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void {
433-
if (!e.affectsConfiguration('git.blame.editorDecoration.enabled') &&
479+
async provideHover(document: TextDocument, position: Position, token: CancellationToken): Promise<Hover | undefined> {
480+
if (token.isCancellationRequested) {
481+
return undefined;
482+
}
483+
484+
const textEditor = window.activeTextEditor;
485+
if (!textEditor) {
486+
return undefined;
487+
}
488+
489+
// Position must be at the end of the line
490+
if (position.character !== document.lineAt(position.line).range.end.character) {
491+
return undefined;
492+
}
493+
494+
// Get blame information
495+
const blameInformation = this._controller.textEditorBlameInformation
496+
.get(textEditor)?.find(blame => blame.lineNumber === position.line);
497+
498+
if (!blameInformation || typeof blameInformation.blameInformation === 'string') {
499+
return undefined;
500+
}
501+
502+
const contents = await this._controller.getBlameInformationDetailedHover(textEditor.document.uri, blameInformation.blameInformation);
503+
504+
if (!contents || token.isCancellationRequested) {
505+
return undefined;
506+
}
507+
508+
return { range: getEditorDecorationRange(position.line), contents: [contents] };
509+
}
510+
511+
private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void {
512+
if (e &&
513+
!e.affectsConfiguration('git.blame.editorDecoration.enabled') &&
434514
!e.affectsConfiguration('git.blame.editorDecoration.template')) {
435515
return;
436516
}
437517

438-
for (const textEditor of window.visibleTextEditors) {
439-
if (this._getConfiguration().enabled) {
440-
this._updateDecorations(textEditor);
441-
} else {
442-
textEditor.setDecorations(this._decorationType, []);
518+
if (this._getConfiguration().enabled) {
519+
if (window.activeTextEditor) {
520+
this._registerHoverProvider();
521+
this._updateDecorations(window.activeTextEditor);
443522
}
523+
} else {
524+
this._decoration?.dispose();
525+
this._decoration = undefined;
526+
527+
this._hoverDisposable?.dispose();
528+
this._hoverDisposable = undefined;
444529
}
445530
}
446531

@@ -449,11 +534,15 @@ class GitBlameEditorDecoration {
449534
return;
450535
}
451536

537+
// Clear decorations
452538
for (const editor of window.visibleTextEditors) {
453539
if (editor !== window.activeTextEditor) {
454-
editor.setDecorations(this._decorationType, []);
540+
editor.setDecorations(this.decoration, []);
455541
}
456542
}
543+
544+
// Register hover provider
545+
this._registerHoverProvider();
457546
}
458547

459548
private _getConfiguration(): { enabled: boolean; template: string } {
@@ -472,14 +561,14 @@ class GitBlameEditorDecoration {
472561

473562
// Only support resources with `file` and `git` schemes
474563
if (textEditor.document.uri.scheme !== 'file' && !isGitUri(textEditor.document.uri)) {
475-
textEditor.setDecorations(this._decorationType, []);
564+
textEditor.setDecorations(this.decoration, []);
476565
return;
477566
}
478567

479568
// Get blame information
480569
const blameInformation = this._controller.textEditorBlameInformation.get(textEditor);
481570
if (!blameInformation) {
482-
textEditor.setDecorations(this._decorationType, []);
571+
textEditor.setDecorations(this.decoration, []);
483572
return;
484573
}
485574

@@ -488,32 +577,43 @@ class GitBlameEditorDecoration {
488577
const contentText = typeof blame.blameInformation !== 'string'
489578
? this._controller.formatBlameInformationMessage(template, blame.blameInformation)
490579
: blame.blameInformation;
491-
const hoverMessage = typeof blame.blameInformation !== 'string'
492-
? this._controller.getBlameInformationHover(textEditor.document.uri, blame.blameInformation)
493-
: undefined;
494580

495-
return this._createDecoration(blame.lineNumber, contentText, hoverMessage);
581+
return this._createDecoration(blame.lineNumber, contentText);
496582
});
497583

498-
textEditor.setDecorations(this._decorationType, decorations);
584+
textEditor.setDecorations(this.decoration, decorations);
499585
}
500586

501-
private _createDecoration(lineNumber: number, contentText: string, hoverMessage: MarkdownString | undefined): DecorationOptions {
502-
const position = new Position(lineNumber, Number.MAX_SAFE_INTEGER);
503-
587+
private _createDecoration(lineNumber: number, contentText: string): DecorationOptions {
504588
return {
505-
hoverMessage,
506-
range: new Range(position, position),
589+
range: getEditorDecorationRange(lineNumber),
507590
renderOptions: {
508591
after: {
509-
contentText: `${contentText}`,
592+
contentText,
510593
margin: '0 0 0 50px'
511594
}
512595
},
513596
};
514597
}
515598

599+
private _registerHoverProvider(): void {
600+
this._hoverDisposable?.dispose();
601+
602+
if (window.activeTextEditor?.document.uri.scheme === 'file' ||
603+
window.activeTextEditor?.document.uri.scheme === 'git') {
604+
this._hoverDisposable = languages.registerHoverProvider({
605+
pattern: window.activeTextEditor.document.uri.fsPath
606+
}, this);
607+
}
608+
}
609+
516610
dispose() {
611+
this._decoration?.dispose();
612+
this._decoration = undefined;
613+
614+
this._hoverDisposable?.dispose();
615+
this._hoverDisposable = undefined;
616+
517617
this._disposables = dispose(this._disposables);
518618
}
519619
}

0 commit comments

Comments
 (0)