570 lines
18 KiB

'use strict';
import * as paths from 'path';
import {
ConfigurationChangeEvent,
DecorationRangeBehavior,
Disposable,
Event,
EventEmitter,
OverviewRulerLane,
Progress,
ProgressLocation,
TextDocument,
TextEditor,
TextEditorDecorationType,
TextEditorViewColumnChangeEvent,
ThemeColor,
Uri,
window,
workspace,
} from 'vscode';
import { AnnotationProviderBase, AnnotationStatus, TextEditorCorrelationKey } from './annotationProvider';
import {
AnnotationsToggleMode,
BlameHighlightLocations,
ChangesLocations,
configuration,
FileAnnotationType,
} from '../configuration';
import { Colors, ContextKeys, isTextEditor, setContext } from '../constants';
import { Container } from '../container';
import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider';
import { GutterChangesAnnotationProvider } from './gutterChangesAnnotationProvider';
import { GutterHeatmapBlameAnnotationProvider } from './gutterHeatmapBlameAnnotationProvider';
import { KeyboardScope } from '../keyboard';
import { Logger } from '../logger';
import { Functions, Iterables } from '../system';
import {
DocumentBlameStateChangeEvent,
DocumentDirtyStateChangeEvent,
GitDocumentState,
} from '../trackers/gitDocumentTracker';
export enum AnnotationClearReason {
User = 'User',
BlameabilityChanged = 'BlameabilityChanged',
ColumnChanged = 'ColumnChanged',
Disposing = 'Disposing',
DocumentChanged = 'DocumentChanged',
DocumentClosed = 'DocumentClosed',
}
export const Decorations = {
gutterBlameAnnotation: window.createTextEditorDecorationType({
rangeBehavior: DecorationRangeBehavior.ClosedOpen,
textDecoration: 'none',
}),
gutterBlameHighlight: undefined as TextEditorDecorationType | undefined,
changesLineChangedAnnotation: undefined as TextEditorDecorationType | undefined,
changesLineAddedAnnotation: undefined as TextEditorDecorationType | undefined,
changesLineDeletedAnnotation: undefined as TextEditorDecorationType | undefined,
};
export class FileAnnotationController implements Disposable {
private _onDidToggleAnnotations = new EventEmitter<void>();
get onDidToggleAnnotations(): Event<void> {
return this._onDidToggleAnnotations.event;
}
private _annotationsDisposable: Disposable | undefined;
private _annotationProviders = new Map<TextEditorCorrelationKey, AnnotationProviderBase>();
private _disposable: Disposable;
private _editor: TextEditor | undefined;
private _keyboardScope: KeyboardScope | undefined = undefined;
private readonly _toggleModes: Map<FileAnnotationType, AnnotationsToggleMode>;
private _annotationType: FileAnnotationType | undefined = undefined;
constructor() {
this._disposable = Disposable.from(configuration.onDidChange(this.onConfigurationChanged, this));
this._toggleModes = new Map<FileAnnotationType, AnnotationsToggleMode>();
this.onConfigurationChanged(configuration.initializingChangeEvent);
}
dispose() {
void this.clearAll();
Decorations.gutterBlameAnnotation?.dispose();
Decorations.gutterBlameHighlight?.dispose();
Decorations.changesLineChangedAnnotation?.dispose();
Decorations.changesLineAddedAnnotation?.dispose();
Decorations.changesLineDeletedAnnotation?.dispose();
this._annotationsDisposable?.dispose();
this._disposable?.dispose();
}
private onConfigurationChanged(e: ConfigurationChangeEvent) {
const cfg = Container.config;
if (configuration.changed(e, 'blame', 'highlight')) {
Decorations.gutterBlameHighlight?.dispose();
Decorations.gutterBlameHighlight = undefined;
const highlight = cfg.blame.highlight;
if (highlight.enabled) {
const { locations } = highlight;
// TODO@eamodio: Read from the theme color when the API exists
const gutterHighlightColor = '#00bcf2'; // new ThemeColor(Colors.LineHighlightOverviewRulerColor)
const gutterHighlightUri = locations.includes(BlameHighlightLocations.Gutter)
? Uri.parse(
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><rect fill='${gutterHighlightColor}' fill-opacity='0.6' x='7' y='0' width='3' height='18'/></svg>`,
)}`,
)
: undefined;
Decorations.gutterBlameHighlight = window.createTextEditorDecorationType({
gutterIconPath: gutterHighlightUri,
gutterIconSize: 'contain',
isWholeLine: true,
overviewRulerLane: OverviewRulerLane.Right,
backgroundColor: locations.includes(BlameHighlightLocations.Line)
? new ThemeColor(Colors.LineHighlightBackgroundColor)
: undefined,
overviewRulerColor: locations.includes(BlameHighlightLocations.Overview)
? new ThemeColor(Colors.LineHighlightOverviewRulerColor)
: undefined,
});
}
}
if (configuration.changed(e, 'changes', 'locations')) {
Decorations.changesLineAddedAnnotation?.dispose();
Decorations.changesLineChangedAnnotation?.dispose();
Decorations.changesLineDeletedAnnotation?.dispose();
const { locations } = cfg.changes;
Decorations.changesLineAddedAnnotation = window.createTextEditorDecorationType({
gutterIconPath: locations.includes(ChangesLocations.Gutter)
? Uri.parse(
`data:image/svg+xml,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><rect fill='#587c0c' x='13' y='0' width='3' height='18'/></svg>",
)}`,
)
: undefined,
gutterIconSize: 'contain',
overviewRulerLane: OverviewRulerLane.Left,
overviewRulerColor: locations.includes(ChangesLocations.Overview)
? new ThemeColor('editorOverviewRuler.addedForeground')
: undefined,
});
Decorations.changesLineChangedAnnotation = window.createTextEditorDecorationType({
gutterIconPath: locations.includes(ChangesLocations.Gutter)
? Uri.parse(
`data:image/svg+xml,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><rect fill='#0c7d9d' x='13' y='0' width='3' height='18'/></svg>",
)}`,
)
: undefined,
gutterIconSize: 'contain',
overviewRulerLane: OverviewRulerLane.Left,
overviewRulerColor: locations.includes(ChangesLocations.Overview)
? new ThemeColor('editorOverviewRuler.modifiedForeground')
: undefined,
});
Decorations.changesLineDeletedAnnotation = window.createTextEditorDecorationType({
gutterIconPath: locations.includes(ChangesLocations.Gutter)
? Uri.parse(
`data:image/svg+xml,${encodeURIComponent(
"<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><polygon fill='#94151b' points='13,10 13,18 17,14'/></svg>",
)}`,
)
: undefined,
gutterIconSize: 'contain',
overviewRulerLane: OverviewRulerLane.Left,
overviewRulerColor: locations.includes(ChangesLocations.Overview)
? new ThemeColor('editorOverviewRuler.deletedForeground')
: undefined,
});
}
const initializing = configuration.initializing(e);
if (configuration.changed(e, 'blame', 'toggleMode')) {
this._toggleModes.set(FileAnnotationType.Blame, cfg.blame.toggleMode);
if (!initializing && cfg.blame.toggleMode === AnnotationsToggleMode.File) {
void this.clearAll();
}
}
if (configuration.changed(e, 'changes', 'toggleMode')) {
this._toggleModes.set(FileAnnotationType.Changes, cfg.changes.toggleMode);
if (!initializing && cfg.changes.toggleMode === AnnotationsToggleMode.File) {
void this.clearAll();
}
}
if (configuration.changed(e, 'heatmap', 'toggleMode')) {
this._toggleModes.set(FileAnnotationType.Heatmap, cfg.heatmap.toggleMode);
if (!initializing && cfg.heatmap.toggleMode === AnnotationsToggleMode.File) {
void this.clearAll();
}
}
if (initializing) return;
if (
configuration.changed(e, 'blame') ||
configuration.changed(e, 'changes') ||
configuration.changed(e, 'heatmap') ||
configuration.changed(e, 'hovers') ||
configuration.changed(e, 'defaultDateFormat') ||
configuration.changed(e, 'defaultDateSource') ||
configuration.changed(e, 'defaultDateStyle') ||
configuration.changed(e, 'defaultGravatarsStyle')
) {
// Since the configuration has changed -- reset any visible annotations
for (const provider of this._annotationProviders.values()) {
if (provider == null) continue;
void this.show(provider.editor, provider.annotationType ?? FileAnnotationType.Blame);
}
}
}
private async onActiveTextEditorChanged(editor: TextEditor | undefined) {
if (editor != null && !isTextEditor(editor)) return;
this._editor = editor;
// Logger.log('AnnotationController.onActiveTextEditorChanged', editor && editor.document.uri.fsPath);
if (this.isInWindowToggle()) {
await this.show(editor, this._annotationType!);
return;
}
const provider = this.getProvider(editor);
if (provider == null) {
void setContext(ContextKeys.AnnotationStatus, undefined);
void this.detachKeyboardHook();
} else {
void setContext(ContextKeys.AnnotationStatus, provider.status);
void this.attachKeyboardHook();
}
}
private onBlameStateChanged(e: DocumentBlameStateChangeEvent<GitDocumentState>) {
// Only care if we are becoming un-blameable
if (e.blameable) return;
const editor = window.activeTextEditor;
if (editor == null) return;
void this.clear(editor, AnnotationClearReason.BlameabilityChanged);
}
private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent<GitDocumentState>) {
for (const [key, p] of this._annotationProviders) {
if (!e.document.is(p.document)) continue;
void this.clearCore(key, AnnotationClearReason.DocumentChanged);
}
}
private onTextDocumentClosed(document: TextDocument) {
if (!Container.git.isTrackable(document.uri)) return;
for (const [key, p] of this._annotationProviders) {
if (p.document !== document) continue;
void this.clearCore(key, AnnotationClearReason.DocumentClosed);
}
}
private onTextEditorViewColumnChanged(e: TextEditorViewColumnChangeEvent) {
// FYI https://github.com/Microsoft/vscode/issues/35602
const provider = this.getProvider(e.textEditor);
if (provider == null) {
// If we don't find an exact match, do a fuzzy match (since we can't properly track editors)
const fuzzyProvider = Iterables.find(
this._annotationProviders.values(),
p => p.editor.document === e.textEditor.document,
);
if (fuzzyProvider == null) return;
void this.clearCore(fuzzyProvider.correlationKey, AnnotationClearReason.ColumnChanged);
return;
}
void provider.restore(e.textEditor);
}
private onVisibleTextEditorsChanged(editors: TextEditor[]) {
let provider: AnnotationProviderBase | undefined;
for (const e of editors) {
provider = this.getProvider(e);
if (provider == null) continue;
void provider.restore(e);
}
}
isInWindowToggle(): boolean {
return this.getToggleMode(this._annotationType) === AnnotationsToggleMode.Window;
}
private getToggleMode(annotationType: FileAnnotationType | undefined): AnnotationsToggleMode {
if (annotationType == null) return AnnotationsToggleMode.File;
return this._toggleModes.get(annotationType) ?? AnnotationsToggleMode.File;
}
clear(editor: TextEditor, reason: AnnotationClearReason = AnnotationClearReason.User) {
if (this.isInWindowToggle()) {
return this.clearAll();
}
return this.clearCore(AnnotationProviderBase.getCorrelationKey(editor), reason);
}
async clearAll() {
this._annotationType = undefined;
for (const [key] of this._annotationProviders) {
await this.clearCore(key, AnnotationClearReason.Disposing);
}
}
async getAnnotationType(editor: TextEditor | undefined): Promise<FileAnnotationType | undefined> {
const provider = this.getProvider(editor);
if (provider == null) return undefined;
const trackedDocument = await Container.tracker.get(editor!.document);
if (trackedDocument == null || !trackedDocument.isBlameable) return undefined;
return provider.annotationType;
}
getProvider(editor: TextEditor | undefined): AnnotationProviderBase | undefined {
if (editor == null || editor.document == null) return undefined;
return this._annotationProviders.get(AnnotationProviderBase.getCorrelationKey(editor));
}
async show(
editor: TextEditor | undefined,
type: FileAnnotationType,
shaOrLine?: string | number,
): Promise<boolean> {
if (this.getToggleMode(type) === AnnotationsToggleMode.Window) {
let first = this._annotationType == null;
const reset =
(!first && this._annotationType !== type) ||
(this._annotationType === FileAnnotationType.Changes && typeof shaOrLine === 'string');
this._annotationType = type;
if (reset) {
await this.clearAll();
first = true;
}
if (first) {
for (const e of window.visibleTextEditors) {
if (e === editor) continue;
void this.show(e, type);
}
}
}
if (editor == null) return false; // || editor.viewColumn == null) return false;
this._editor = editor;
const trackedDocument = await Container.tracker.getOrAdd(editor.document);
if (!trackedDocument.isBlameable) return false;
const currentProvider = this.getProvider(editor);
if (currentProvider?.annotationType === type) {
await currentProvider.provideAnnotation(shaOrLine);
await currentProvider.selection(shaOrLine);
return true;
}
const provider = await window.withProgress(
{ location: ProgressLocation.Window },
async (progress: Progress<{ message: string }>) => {
await setContext(ContextKeys.AnnotationStatus, AnnotationStatus.Computing);
const computingAnnotations = this.showAnnotationsCore(
currentProvider,
editor,
type,
shaOrLine,
progress,
);
const provider = await computingAnnotations;
if (editor === this._editor) {
await setContext(ContextKeys.AnnotationStatus, provider?.status);
}
return computingAnnotations;
},
);
return provider != null;
}
async toggle(
editor: TextEditor | undefined,
type: FileAnnotationType,
shaOrLine?: string | number,
on?: boolean,
): Promise<boolean> {
if (editor != null) {
const trackedDocument = await Container.tracker.getOrAdd(editor.document);
if ((type === FileAnnotationType.Changes && !trackedDocument.isTracked) || !trackedDocument.isBlameable) {
return false;
}
}
const provider = this.getProvider(editor);
if (provider == null) return this.show(editor, type, shaOrLine);
const reopen =
provider.annotationType !== type || (type === FileAnnotationType.Changes && typeof shaOrLine === 'string');
if (on === true && !reopen) return true;
if (this.isInWindowToggle()) {
await this.clearAll();
} else {
await this.clearCore(provider.correlationKey, AnnotationClearReason.User);
}
if (!reopen) return false;
return this.show(editor, type, shaOrLine);
}
private async attachKeyboardHook() {
// Allows pressing escape to exit the annotations
if (this._keyboardScope == null) {
this._keyboardScope = await Container.keyboard.beginScope({
escape: {
onDidPressKey: async () => {
const e = this._editor;
if (e == null) return undefined;
await this.clear(e, AnnotationClearReason.User);
return undefined;
},
},
});
}
}
private async clearCore(key: TextEditorCorrelationKey, reason: AnnotationClearReason) {
const provider = this._annotationProviders.get(key);
if (provider == null) return;
Logger.log(`${reason}:`, `Clear annotations for ${key}`);
this._annotationProviders.delete(key);
provider.dispose();
if (this._annotationProviders.size === 0 || key === AnnotationProviderBase.getCorrelationKey(this._editor)) {
await setContext(ContextKeys.AnnotationStatus, undefined);
await this.detachKeyboardHook();
}
if (this._annotationProviders.size === 0) {
Logger.log('Remove all listener registrations for annotations');
this._annotationsDisposable?.dispose();
this._annotationsDisposable = undefined;
}
this._onDidToggleAnnotations.fire();
}
private async detachKeyboardHook() {
if (this._keyboardScope == null) return;
await this._keyboardScope.dispose();
this._keyboardScope = undefined;
}
private async showAnnotationsCore(
currentProvider: AnnotationProviderBase | undefined,
editor: TextEditor,
type: FileAnnotationType,
shaOrLine?: string | number,
progress?: Progress<{ message: string }>,
): Promise<AnnotationProviderBase | undefined> {
if (progress != null) {
let annotationsLabel = 'annotations';
switch (type) {
case FileAnnotationType.Blame:
annotationsLabel = 'blame annotations';
break;
case FileAnnotationType.Changes:
annotationsLabel = 'changes annotations';
break;
case FileAnnotationType.Heatmap:
annotationsLabel = 'heatmap annotations';
break;
}
progress.report({
message: `Computing ${annotationsLabel} for ${paths.basename(editor.document.fileName)}`,
});
}
// Allows pressing escape to exit the annotations
await this.attachKeyboardHook();
const trackedDocument = await Container.tracker.getOrAdd(editor.document);
let provider: AnnotationProviderBase | undefined = undefined;
switch (type) {
case FileAnnotationType.Blame:
provider = new GutterBlameAnnotationProvider(editor, trackedDocument);
break;
case FileAnnotationType.Changes:
provider = new GutterChangesAnnotationProvider(editor, trackedDocument);
break;
case FileAnnotationType.Heatmap:
provider = new GutterHeatmapBlameAnnotationProvider(editor, trackedDocument);
break;
}
if (provider == null || !(await provider.validate())) return undefined;
if (currentProvider != null) {
await this.clearCore(currentProvider.correlationKey, AnnotationClearReason.User);
}
if (this._annotationsDisposable == null && this._annotationProviders.size === 0) {
Logger.log('Add listener registrations for annotations');
this._annotationsDisposable = Disposable.from(
window.onDidChangeActiveTextEditor(Functions.debounce(this.onActiveTextEditorChanged, 50), this),
window.onDidChangeTextEditorViewColumn(this.onTextEditorViewColumnChanged, this),
window.onDidChangeVisibleTextEditors(Functions.debounce(this.onVisibleTextEditorsChanged, 50), this),
workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this),
Container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this),
Container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this),
);
}
this._annotationProviders.set(provider.correlationKey, provider);
if (await provider.provideAnnotation(shaOrLine)) {
this._onDidToggleAnnotations.fire();
return provider;
}
await this.clearCore(provider.correlationKey, AnnotationClearReason.Disposing);
return undefined;
}
}