'use strict'; import { Functions } from './system'; import { DecorationRenderOptions, Disposable, Event, EventEmitter, ExtensionContext, OverviewRulerLane, TextDocument, TextDocumentChangeEvent, TextEditor, TextEditorDecorationType, TextEditorViewColumnChangeEvent, window, workspace } from 'vscode'; import { BlameAnnotationProvider } from './blameAnnotationProvider'; import { TextDocumentComparer, TextEditorComparer } from './comparers'; import { IBlameConfig } from './configuration'; import { ExtensionKey } from './constants'; import { BlameabilityChangeEvent, GitContextTracker, GitService, GitUri } from './gitService'; import { Logger } from './logger'; import { WhitespaceController } from './whitespaceController'; export const BlameDecorations = { annotation: window.createTextEditorDecorationType({ before: { margin: '0 1.75em 0 0' }, after: { margin: '0 0 0 4em' } } as DecorationRenderOptions), highlight: undefined as TextEditorDecorationType | undefined }; export class BlameAnnotationController extends Disposable { private _onDidToggleBlameAnnotations = new EventEmitter(); get onDidToggleBlameAnnotations(): Event { return this._onDidToggleBlameAnnotations.event; } private _annotationProviders: Map = new Map(); private _blameAnnotationsDisposable: Disposable | undefined; private _config: IBlameConfig; private _disposable: Disposable; private _whitespaceController: WhitespaceController | undefined; constructor(private context: ExtensionContext, private git: GitService, private gitContextTracker: GitContextTracker) { super(() => this.dispose()); this._onConfigurationChanged(); const subscriptions: Disposable[] = []; subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); this._disposable = Disposable.from(...subscriptions); } dispose() { this._annotationProviders.forEach(async (p, i) => await this.clear(i)); BlameDecorations.annotation && BlameDecorations.annotation.dispose(); BlameDecorations.highlight && BlameDecorations.highlight.dispose(); this._blameAnnotationsDisposable && this._blameAnnotationsDisposable.dispose(); this._whitespaceController && this._whitespaceController.dispose(); this._disposable && this._disposable.dispose(); } private _onConfigurationChanged() { let toggleWhitespace = workspace.getConfiguration(`${ExtensionKey}.advanced.toggleWhitespace`).get('enabled'); if (!toggleWhitespace) { // Until https://github.com/Microsoft/vscode/issues/11485 is fixed we need to toggle whitespace for non-monospace fonts and ligatures // TODO: detect monospace font toggleWhitespace = workspace.getConfiguration('editor').get('fontLigatures'); } if (toggleWhitespace && !this._whitespaceController) { this._whitespaceController = new WhitespaceController(); } else if (!toggleWhitespace && this._whitespaceController) { this._whitespaceController.dispose(); this._whitespaceController = undefined; } const cfg = workspace.getConfiguration(ExtensionKey).get('blame')!; if (cfg.annotation.highlight !== (this._config && this._config.annotation.highlight)) { BlameDecorations.highlight && BlameDecorations.highlight.dispose(); switch (cfg.annotation.highlight) { case 'gutter': BlameDecorations.highlight = window.createTextEditorDecorationType({ dark: { gutterIconPath: this.context.asAbsolutePath('images/blame-dark.svg'), overviewRulerColor: 'rgba(255, 255, 255, 0.75)' }, light: { gutterIconPath: this.context.asAbsolutePath('images/blame-light.svg'), overviewRulerColor: 'rgba(0, 0, 0, 0.75)' }, gutterIconSize: 'contain', overviewRulerLane: OverviewRulerLane.Right }); break; case 'line': BlameDecorations.highlight = window.createTextEditorDecorationType({ dark: { backgroundColor: 'rgba(255, 255, 255, 0.15)', overviewRulerColor: 'rgba(255, 255, 255, 0.75)' }, light: { backgroundColor: 'rgba(0, 0, 0, 0.15)', overviewRulerColor: 'rgba(0, 0, 0, 0.75)' }, overviewRulerLane: OverviewRulerLane.Right, isWholeLine: true }); break; case 'both': BlameDecorations.highlight = window.createTextEditorDecorationType({ dark: { backgroundColor: 'rgba(255, 255, 255, 0.15)', gutterIconPath: this.context.asAbsolutePath('images/blame-dark.svg'), overviewRulerColor: 'rgba(255, 255, 255, 0.75)' }, light: { backgroundColor: 'rgba(0, 0, 0, 0.15)', gutterIconPath: this.context.asAbsolutePath('images/blame-light.svg'), overviewRulerColor: 'rgba(0, 0, 0, 0.75)' }, gutterIconSize: 'contain', overviewRulerLane: OverviewRulerLane.Right, isWholeLine: true }); break; default: BlameDecorations.highlight = undefined; break; } } this._config = cfg; } async clear(column: number) { const provider = this._annotationProviders.get(column); if (!provider) return; this._annotationProviders.delete(column); await provider.dispose(); if (this._annotationProviders.size === 0) { Logger.log(`Remove listener registrations for blame annotations`); this._blameAnnotationsDisposable && this._blameAnnotationsDisposable.dispose(); this._blameAnnotationsDisposable = undefined; } this._onDidToggleBlameAnnotations.fire(); } async showBlameAnnotation(editor: TextEditor, shaOrLine?: string | number): Promise { if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; const currentProvider = this._annotationProviders.get(editor.viewColumn || -1); if (currentProvider && TextEditorComparer.equals(currentProvider.editor, editor)) { await currentProvider.setSelection(shaOrLine); return true; } const gitUri = await GitUri.fromUri(editor.document.uri, this.git); const provider = new BlameAnnotationProvider(this.context, this.git, this._whitespaceController, editor, gitUri); if (!await provider.supportsBlame()) return false; if (currentProvider) { await this.clear(currentProvider.editor.viewColumn || -1); } if (!this._blameAnnotationsDisposable && this._annotationProviders.size === 0) { Logger.log(`Add listener registrations for blame annotations`); const subscriptions: Disposable[] = []; subscriptions.push(window.onDidChangeVisibleTextEditors(Functions.debounce(this._onVisibleTextEditorsChanged, 100), this)); subscriptions.push(window.onDidChangeTextEditorViewColumn(this._onTextEditorViewColumnChanged, this)); subscriptions.push(workspace.onDidChangeTextDocument(this._onTextDocumentChanged, this)); subscriptions.push(workspace.onDidCloseTextDocument(this._onTextDocumentClosed, this)); subscriptions.push(this.gitContextTracker.onDidBlameabilityChange(this._onBlameabilityChanged, this)); this._blameAnnotationsDisposable = Disposable.from(...subscriptions); } this._annotationProviders.set(editor.viewColumn || -1, provider); if (await provider.provideBlameAnnotation(shaOrLine)) { this._onDidToggleBlameAnnotations.fire(); return true; } return false; } isAnnotating(editor: TextEditor): boolean { if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; return !!this._annotationProviders.get(editor.viewColumn || -1); } async toggleBlameAnnotation(editor: TextEditor, shaOrLine?: string | number): Promise { if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; const provider = this._annotationProviders.get(editor.viewColumn || -1); if (!provider) return this.showBlameAnnotation(editor, shaOrLine); await this.clear(provider.editor.viewColumn || -1); return false; } private _onBlameabilityChanged(e: BlameabilityChangeEvent) { if (e.blameable || !e.editor) return; for (const [key, p] of this._annotationProviders) { if (!TextDocumentComparer.equals(p.document, e.editor.document)) continue; Logger.log('BlameabilityChanged:', `Clear blame annotations for column ${key}`); this.clear(key); } } private _onTextDocumentChanged(e: TextDocumentChangeEvent) { for (const [key, p] of this._annotationProviders) { if (!TextDocumentComparer.equals(p.document, e.document)) continue; // We have to defer because isDirty is not reliable inside this event setTimeout(() => { // If the document is dirty all is fine, just kick out since the GitContextTracker will handle it if (e.document.isDirty) return; // If the document isn't dirty, it is very likely this event was triggered by an outside edit of this document // Which means the document has been reloaded and the blame annotations have been removed, so we need to update (clear) our state tracking Logger.log('TextDocumentChanged:', `Clear blame annotations for column ${key}`); this.clear(key); }, 1); } } private _onTextDocumentClosed(e: TextDocument) { for (const [key, p] of this._annotationProviders) { if (!TextDocumentComparer.equals(p.document, e)) continue; Logger.log('TextDocumentClosed:', `Clear blame annotations for column ${key}`); this.clear(key); } } private async _onTextEditorViewColumnChanged(e: TextEditorViewColumnChangeEvent) { const viewColumn = e.viewColumn || -1; Logger.log('TextEditorViewColumnChanged:', `Clear blame annotations for column ${viewColumn}`); await this.clear(viewColumn); for (const [key, p] of this._annotationProviders) { if (!TextEditorComparer.equals(p.editor, e.textEditor)) continue; Logger.log('TextEditorViewColumnChanged:', `Clear blame annotations for column ${key}`); await this.clear(key); } } private async _onVisibleTextEditorsChanged(e: TextEditor[]) { if (e.every(_ => _.document.uri.scheme === 'inmemory')) return; for (const [key, p] of this._annotationProviders) { if (e.some(_ => TextEditorComparer.equals(p.editor, _))) continue; Logger.log('VisibleTextEditorsChanged:', `Clear blame annotations for column ${key}`); this.clear(key); } } }