From 28b87cfc92633e06b2a6282f13311962ed54a698 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Fri, 23 Mar 2018 03:04:38 -0400 Subject: [PATCH] Refactors current line tracking Splits currentLineController into: lineAnnotationController - end of line annotation lineHoverController - hovers to current line(s) statusBarController - blame on the statusbar --- CHANGELOG.md | 2 + README.md | 2 - package.json | 18 - src/annotations/annotationController.ts | 485 ------------------------- src/annotations/fileAnnotationController.ts | 485 +++++++++++++++++++++++++ src/annotations/lineAnnotationController.ts | 202 +++++++++++ src/annotations/lineHoverController.ts | 180 ++++++++++ src/commands.ts | 4 - src/commands/clearFileAnnotations.ts | 2 +- src/commands/common.ts | 4 +- src/commands/openFileRevision.ts | 2 +- src/commands/openWorkingFile.ts | 2 +- src/commands/showFileBlame.ts | 34 -- src/commands/showLineBlame.ts | 22 -- src/commands/toggleFileBlame.ts | 2 +- src/commands/toggleLineBlame.ts | 2 +- src/container.ts | 36 +- src/currentLineController.ts | 539 ---------------------------- src/statusBarController.ts | 136 +++++++ src/trackers/gitLineTracker.ts | 110 +++++- src/trackers/lineTracker.ts | 34 +- 21 files changed, 1161 insertions(+), 1142 deletions(-) delete mode 100644 src/annotations/annotationController.ts create mode 100644 src/annotations/fileAnnotationController.ts create mode 100644 src/annotations/lineAnnotationController.ts create mode 100644 src/annotations/lineHoverController.ts delete mode 100644 src/commands/showFileBlame.ts delete mode 100644 src/commands/showLineBlame.ts delete mode 100644 src/currentLineController.ts create mode 100644 src/statusBarController.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ac721e..50f838a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds `${agoOrDate}` and `${authorAgoOrDate}` tokens to `gitlens.blame.format`, `gitlens.currentLine.format`, `gitlens.explorers.commitFormat`, `gitlens.explorers.stashFormat`, and `gitlens.statusBar.format` settings which will honor the `gitlens.defaultDateStyle` setting — closes [#312](https://github.com/eamodio/vscode-gitlens/issues/312) ### Removed +- Removes the unnecessary *Show File Blame Annotations* (`gitlens.showFileBlame`) command — *Toggle File Blame Annotations* (`gitlens.toggleFileBlame`) provides similar functionality +- Removes the unnecessary *Show Line Blame Annotations* (`gitlens.showLineBlame`) command — *Toggle Line Blame Annotations* (`gitlens.toggleLineBlame`) provides similar functionality - Removes the *Open Working File* command from the editor toolbar when the built-in *Open File* command is visible ### Fixed diff --git a/README.md b/README.md index 4724b04..fa1f12c 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,6 @@ An on-demand, [customizable](#gitlens-results-view-settings "Jump to the GitLens - Adds an unobtrusive, [customizable](#current-line-blame-settings "Jump to the Current Line Blame settings"), and [themable](#themable-colors "Jump to the Themable Colors"), **blame annotation** at the end of the current line - Contains the author, date, and message of the current line's most recent commit (by [default](#current-line-blame-settings "Jump to the Current Line Blame settings")) - - Adds a *Show Line Blame Annotations* command (`gitlens.showLineBlame`) - Adds a *Toggle Line Blame Annotations* command (`gitlens.toggleLineBlame`) to toggle the blame annotation on and off --- @@ -258,7 +257,6 @@ An on-demand, [customizable](#gitlens-results-view-settings "Jump to the GitLens - 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 *Show File Blame Annotations* command (`gitlens.showFileBlame`) - 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 diff --git a/package.json b/package.json index 59ef82d..4b9a8f4 100644 --- a/package.json +++ b/package.json @@ -1182,16 +1182,6 @@ "category": "GitLens" }, { - "command": "gitlens.showFileBlame", - "title": "Show File Blame Annotations", - "category": "GitLens" - }, - { - "command": "gitlens.showLineBlame", - "title": "Show Line Blame Annotations", - "category": "GitLens" - }, - { "command": "gitlens.toggleFileBlame", "title": "Toggle File Blame Annotations", "category": "GitLens", @@ -1750,14 +1740,6 @@ "when": "gitlens:enabled" }, { - "command": "gitlens.showFileBlame", - "when": "gitlens:activeIsBlameable" - }, - { - "command": "gitlens.showLineBlame", - "when": "gitlens:activeIsBlameable" - }, - { "command": "gitlens.toggleFileBlame", "when": "gitlens:activeIsBlameable" }, diff --git a/src/annotations/annotationController.ts b/src/annotations/annotationController.ts deleted file mode 100644 index 3911298..0000000 --- a/src/annotations/annotationController.ts +++ /dev/null @@ -1,485 +0,0 @@ -'use strict'; -import { Functions, Iterables } from '../system'; -import { ConfigurationChangeEvent, DecorationRangeBehavior, DecorationRenderOptions, Disposable, Event, EventEmitter, OverviewRulerLane, Progress, ProgressLocation, TextDocument, TextEditor, TextEditorDecorationType, TextEditorViewColumnChangeEvent, ThemeColor, window, workspace } from 'vscode'; -import { AnnotationProviderBase, AnnotationStatus, TextEditorCorrelationKey } from './annotationProvider'; -import { AnnotationsToggleMode, configuration, FileAnnotationType, HighlightLocations } from '../configuration'; -import { CommandContext, isTextEditor, setCommandContext } from '../constants'; -import { Container } from '../container'; -import { DocumentBlameStateChangeEvent, DocumentDirtyStateChangeEvent, GitDocumentState } from '../trackers/gitDocumentTracker'; -import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider'; -import { HeatmapBlameAnnotationProvider } from './heatmapBlameAnnotationProvider'; -import { KeyboardScope, KeyCommand, Keys } from '../keyboard'; -import { Logger } from '../logger'; -import { RecentChangesAnnotationProvider } from './recentChangesAnnotationProvider'; -import * as path from 'path'; - -export enum AnnotationClearReason { - User = 'User', - BlameabilityChanged = 'BlameabilityChanged', - ColumnChanged = 'ColumnChanged', - Disposing = 'Disposing', - DocumentChanged = 'DocumentChanged', - DocumentClosed = 'DocumentClosed' -} - -export const Decorations = { - blameAnnotation: window.createTextEditorDecorationType({ - rangeBehavior: DecorationRangeBehavior.ClosedOpen, - textDecoration: 'none' - } as DecorationRenderOptions), - blameHighlight: undefined as TextEditorDecorationType | undefined, - heatmapAnnotation: window.createTextEditorDecorationType({} as DecorationRenderOptions), - heatmapHighlight: undefined as TextEditorDecorationType | undefined, - recentChangesAnnotation: undefined as TextEditorDecorationType | undefined, - recentChangesHighlight: undefined as TextEditorDecorationType | undefined -}; - -export class AnnotationController extends Disposable { - - private _onDidToggleAnnotations = new EventEmitter(); - get onDidToggleAnnotations(): Event { - return this._onDidToggleAnnotations.event; - } - - private _annotationsDisposable: Disposable | undefined; - private _annotationProviders: Map = new Map(); - private _disposable: Disposable; - private _editor: TextEditor | undefined; - private _keyboardScope: KeyboardScope | undefined = undefined; - private readonly _toggleModes: Map; - private _annotationType: FileAnnotationType | undefined = undefined; - - constructor() { - super(() => this.dispose()); - - this._disposable = Disposable.from( - configuration.onDidChange(this.onConfigurationChanged, this) - ); - - this._toggleModes = new Map(); - this.onConfigurationChanged(configuration.initializingChangeEvent); - } - - dispose() { - this.clearAll(); - - Decorations.blameAnnotation && Decorations.blameAnnotation.dispose(); - Decorations.blameHighlight && Decorations.blameHighlight.dispose(); - - this._annotationsDisposable && this._annotationsDisposable.dispose(); - this._disposable && this._disposable.dispose(); - } - - private onConfigurationChanged(e: ConfigurationChangeEvent) { - const initializing = configuration.initializing(e); - - const cfg = Container.config; - - if (initializing || configuration.changed(e, configuration.name('blame')('highlight').value)) { - Decorations.blameHighlight && Decorations.blameHighlight.dispose(); - - const cfgHighlight = cfg.blame.highlight; - - if (cfgHighlight.enabled) { - Decorations.blameHighlight = window.createTextEditorDecorationType({ - gutterIconSize: 'contain', - isWholeLine: true, - overviewRulerLane: OverviewRulerLane.Right, - backgroundColor: cfgHighlight.locations.includes(HighlightLocations.Line) - ? new ThemeColor('gitlens.lineHighlightBackgroundColor') - : undefined, - overviewRulerColor: cfgHighlight.locations.includes(HighlightLocations.Overview) - ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor') - : undefined, - dark: { - gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) - ? Container.context.asAbsolutePath('images/dark/highlight-gutter.svg') - : undefined - }, - light: { - gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) - ? Container.context.asAbsolutePath('images/light/highlight-gutter.svg') - : undefined - } - }); - } - else { - Decorations.blameHighlight = undefined; - } - } - - if (initializing || configuration.changed(e, configuration.name('recentChanges')('highlight').value)) { - Decorations.recentChangesAnnotation && Decorations.recentChangesAnnotation.dispose(); - - const cfgHighlight = cfg.recentChanges.highlight; - - Decorations.recentChangesAnnotation = window.createTextEditorDecorationType({ - gutterIconSize: 'contain', - isWholeLine: true, - overviewRulerLane: OverviewRulerLane.Right, - backgroundColor: cfgHighlight.locations.includes(HighlightLocations.Line) - ? new ThemeColor('gitlens.lineHighlightBackgroundColor') - : undefined, - overviewRulerColor: cfgHighlight.locations.includes(HighlightLocations.Overview) - ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor') - : undefined, - dark: { - gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) - ? Container.context.asAbsolutePath('images/dark/highlight-gutter.svg') - : undefined - }, - light: { - gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) - ? Container.context.asAbsolutePath('images/light/highlight-gutter.svg') - : undefined - } - }); - } - - if (initializing || configuration.changed(e, configuration.name('blame')('toggleMode').value)) { - this._toggleModes.set(FileAnnotationType.Blame, cfg.blame.toggleMode); - if (!initializing && cfg.blame.toggleMode === AnnotationsToggleMode.File) { - this.clearAll(); - } - } - - if (initializing || configuration.changed(e, configuration.name('heatmap')('toggleMode').value)) { - this._toggleModes.set(FileAnnotationType.Heatmap, cfg.heatmap.toggleMode); - if (!initializing && cfg.heatmap.toggleMode === AnnotationsToggleMode.File) { - this.clearAll(); - } - } - - if (initializing || configuration.changed(e, configuration.name('recentChanges')('toggleMode').value)) { - this._toggleModes.set(FileAnnotationType.RecentChanges, cfg.recentChanges.toggleMode); - if (!initializing && cfg.recentChanges.toggleMode === AnnotationsToggleMode.File) { - this.clearAll(); - } - } - - if (initializing) return; - - if (configuration.changed(e, configuration.name('blame').value) || - configuration.changed(e, configuration.name('recentChanges').value) || - configuration.changed(e, configuration.name('hovers').value)) { - // Since the configuration has changed -- reset any visible annotations - for (const provider of this._annotationProviders.values()) { - if (provider === undefined) continue; - - if (provider.annotationType === FileAnnotationType.RecentChanges) { - provider.reset({ decoration: Decorations.recentChangesAnnotation!, highlightDecoration: Decorations.recentChangesHighlight }); - } - else if (provider.annotationType === FileAnnotationType.Blame) { - provider.reset({ decoration: Decorations.blameAnnotation, highlightDecoration: Decorations.blameHighlight }); - } - else { - this.showAnnotations(provider.editor, FileAnnotationType.Heatmap); - } - } - } - } - - private async onActiveTextEditorChanged(editor: TextEditor | undefined) { - if (editor !== undefined && !isTextEditor(editor)) return; - - this._editor = editor; - // Logger.log('AnnotationController.onActiveTextEditorChanged', editor && editor.document.uri.fsPath); - - if (this.isInWindowToggle()) { - await this.showAnnotations(editor, this._annotationType!); - - return; - } - - const provider = this.getProvider(editor); - if (provider === undefined) { - setCommandContext(CommandContext.AnnotationStatus, undefined); - this.detachKeyboardHook(); - } - else { - setCommandContext(CommandContext.AnnotationStatus, provider.status); - this.attachKeyboardHook(); - } - } - - private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { - // Only care if we are becoming un-blameable - if (e.blameable) return; - - const editor = window.activeTextEditor; - if (editor === undefined) return; - - this.clear(editor, AnnotationClearReason.BlameabilityChanged); - } - - private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { - for (const [key, p] of this._annotationProviders) { - if (!e.document.is(p.document)) continue; - - 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; - - 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 === undefined) { - // 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; - - this.clearCore(fuzzyProvider.correlationKey, AnnotationClearReason.ColumnChanged); - - return; - } - - provider.restore(e.textEditor); - } - - private onVisibleTextEditorsChanged(editors: TextEditor[]) { - let provider: AnnotationProviderBase | undefined; - for (const e of editors) { - provider = this.getProvider(e); - if (provider === undefined) continue; - - provider.restore(e); - } - } - - isInWindowToggle(): boolean { - return this.getToggleMode(this._annotationType) === AnnotationsToggleMode.Window; - } - - private getToggleMode(annotationType: FileAnnotationType | undefined): AnnotationsToggleMode { - if (annotationType === undefined) 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 { - const provider = this.getProvider(editor); - if (provider === undefined) return undefined; - - const trackedDocument = await Container.tracker.get(editor!.document); - if (trackedDocument === undefined || !trackedDocument.isBlameable) return undefined; - - return provider.annotationType; - } - - getProvider(editor: TextEditor | undefined): AnnotationProviderBase | undefined { - if (editor === undefined || editor.document === undefined) return undefined; - return this._annotationProviders.get(AnnotationProviderBase.getCorrelationKey(editor)); - } - - async showAnnotations(editor: TextEditor | undefined, type: FileAnnotationType, shaOrLine?: string | number): Promise { - if (this.getToggleMode(type) === AnnotationsToggleMode.Window) { - let first = this._annotationType === undefined; - const reset = !first && this._annotationType !== type; - - this._annotationType = type; - - if (reset) { - await this.clearAll(); - first = true; - } - - if (first) { - for (const e of window.visibleTextEditors) { - if (e === editor) continue; - - this.showAnnotations(e, type); - } - } - } - - if (editor === undefined) return false; // || editor.viewColumn === undefined) 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 !== undefined && currentProvider.annotationType === type) { - await currentProvider.selection(shaOrLine); - return true; - } - - const provider = await window.withProgress({ location: ProgressLocation.Window }, async (progress: Progress<{ message: string }>) => { - await setCommandContext(CommandContext.AnnotationStatus, AnnotationStatus.Computing); - - const computingAnnotations = this.showAnnotationsCore(currentProvider, editor, type, shaOrLine, progress); - const provider = await computingAnnotations; - - if (editor === this._editor) { - await setCommandContext(CommandContext.AnnotationStatus, provider && provider.status); - } - - return computingAnnotations; - }); - - return provider !== undefined; - } - - async toggleAnnotations(editor: TextEditor | undefined, type: FileAnnotationType, shaOrLine?: string | number): Promise { - if (editor !== undefined) { - const trackedDocument = await Container.tracker.getOrAdd(editor.document); - if ((type === FileAnnotationType.RecentChanges && !trackedDocument.isTracked) || !trackedDocument.isBlameable) return false; - } - - const provider = this.getProvider(editor); - if (provider === undefined) return this.showAnnotations(editor!, type, shaOrLine); - - const reopen = provider.annotationType !== type; - - if (this.isInWindowToggle()) { - await this.clearAll(); - } - else { - await this.clearCore(provider.correlationKey, AnnotationClearReason.User); - } - - if (!reopen) return false; - - return this.showAnnotations(editor, type, shaOrLine); - } - - private async attachKeyboardHook() { - // Allows pressing escape to exit the annotations - if (this._keyboardScope === undefined) { - this._keyboardScope = await Container.keyboard.beginScope({ - escape: { - onDidPressKey: async (key: Keys) => { - const e = this._editor; - if (e === undefined) return undefined; - - await this.clear(e, AnnotationClearReason.User); - return undefined; - } - } as KeyCommand - }); - } - } - - private async clearCore(key: TextEditorCorrelationKey, reason: AnnotationClearReason) { - const provider = this._annotationProviders.get(key); - if (provider === undefined) return; - - Logger.log(`${reason}:`, `Clear annotations for ${key}`); - - this._annotationProviders.delete(key); - await provider.dispose(); - - if (this._annotationProviders.size === 0 || key === AnnotationProviderBase.getCorrelationKey(this._editor)) { - await setCommandContext(CommandContext.AnnotationStatus, undefined); - await this.detachKeyboardHook(); - } - - if (this._annotationProviders.size === 0) { - Logger.log(`Remove all listener registrations for annotations`); - - this._annotationsDisposable && this._annotationsDisposable.dispose(); - this._annotationsDisposable = undefined; - } - - this._onDidToggleAnnotations.fire(); - } - - private async detachKeyboardHook() { - if (this._keyboardScope === undefined) 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 { - if (progress !== undefined) { - let annotationsLabel = 'annotations'; - switch (type) { - case FileAnnotationType.Blame: - annotationsLabel = 'blame annotations'; - break; - - case FileAnnotationType.Heatmap: - annotationsLabel = 'heatmap annotations'; - break; - - case FileAnnotationType.RecentChanges: - annotationsLabel = 'recent changes annotations'; - break; - } - - progress!.report({ message: `Computing ${annotationsLabel} for ${path.basename(editor.document.fileName)}` }); - } - - // Allows pressing escape to exit the annotations - 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, Decorations.blameAnnotation, Decorations.blameHighlight); - break; - - case FileAnnotationType.Heatmap: - provider = new HeatmapBlameAnnotationProvider(editor, trackedDocument, Decorations.heatmapAnnotation, Decorations.heatmapHighlight); - break; - - case FileAnnotationType.RecentChanges: - provider = new RecentChangesAnnotationProvider(editor, trackedDocument, Decorations.recentChangesAnnotation!, Decorations.recentChangesHighlight); - break; - } - if (provider === undefined || !(await provider.validate())) return undefined; - - if (currentProvider !== undefined) { - await this.clearCore(currentProvider.correlationKey, AnnotationClearReason.User); - } - - if (!this._annotationsDisposable && 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; - } - - return undefined; - } -} \ No newline at end of file diff --git a/src/annotations/fileAnnotationController.ts b/src/annotations/fileAnnotationController.ts new file mode 100644 index 0000000..23a0e26 --- /dev/null +++ b/src/annotations/fileAnnotationController.ts @@ -0,0 +1,485 @@ +'use strict'; +import { Functions, Iterables } from '../system'; +import { ConfigurationChangeEvent, DecorationRangeBehavior, DecorationRenderOptions, Disposable, Event, EventEmitter, OverviewRulerLane, Progress, ProgressLocation, TextDocument, TextEditor, TextEditorDecorationType, TextEditorViewColumnChangeEvent, ThemeColor, window, workspace } from 'vscode'; +import { AnnotationProviderBase, AnnotationStatus, TextEditorCorrelationKey } from './annotationProvider'; +import { AnnotationsToggleMode, configuration, FileAnnotationType, HighlightLocations } from '../configuration'; +import { CommandContext, isTextEditor, setCommandContext } from '../constants'; +import { Container } from '../container'; +import { DocumentBlameStateChangeEvent, DocumentDirtyStateChangeEvent, GitDocumentState } from '../trackers/gitDocumentTracker'; +import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider'; +import { HeatmapBlameAnnotationProvider } from './heatmapBlameAnnotationProvider'; +import { KeyboardScope, KeyCommand, Keys } from '../keyboard'; +import { Logger } from '../logger'; +import { RecentChangesAnnotationProvider } from './recentChangesAnnotationProvider'; +import * as path from 'path'; + +export enum AnnotationClearReason { + User = 'User', + BlameabilityChanged = 'BlameabilityChanged', + ColumnChanged = 'ColumnChanged', + Disposing = 'Disposing', + DocumentChanged = 'DocumentChanged', + DocumentClosed = 'DocumentClosed' +} + +export const Decorations = { + blameAnnotation: window.createTextEditorDecorationType({ + rangeBehavior: DecorationRangeBehavior.ClosedOpen, + textDecoration: 'none' + } as DecorationRenderOptions), + blameHighlight: undefined as TextEditorDecorationType | undefined, + heatmapAnnotation: window.createTextEditorDecorationType({} as DecorationRenderOptions), + heatmapHighlight: undefined as TextEditorDecorationType | undefined, + recentChangesAnnotation: undefined as TextEditorDecorationType | undefined, + recentChangesHighlight: undefined as TextEditorDecorationType | undefined +}; + +export class FileAnnotationController extends Disposable { + + private _onDidToggleAnnotations = new EventEmitter(); + get onDidToggleAnnotations(): Event { + return this._onDidToggleAnnotations.event; + } + + private _annotationsDisposable: Disposable | undefined; + private _annotationProviders: Map = new Map(); + private _disposable: Disposable; + private _editor: TextEditor | undefined; + private _keyboardScope: KeyboardScope | undefined = undefined; + private readonly _toggleModes: Map; + private _annotationType: FileAnnotationType | undefined = undefined; + + constructor() { + super(() => this.dispose()); + + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this) + ); + + this._toggleModes = new Map(); + this.onConfigurationChanged(configuration.initializingChangeEvent); + } + + dispose() { + this.clearAll(); + + Decorations.blameAnnotation && Decorations.blameAnnotation.dispose(); + Decorations.blameHighlight && Decorations.blameHighlight.dispose(); + + this._annotationsDisposable && this._annotationsDisposable.dispose(); + this._disposable && this._disposable.dispose(); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + const initializing = configuration.initializing(e); + + const cfg = Container.config; + + if (initializing || configuration.changed(e, configuration.name('blame')('highlight').value)) { + Decorations.blameHighlight && Decorations.blameHighlight.dispose(); + + const cfgHighlight = cfg.blame.highlight; + + if (cfgHighlight.enabled) { + Decorations.blameHighlight = window.createTextEditorDecorationType({ + gutterIconSize: 'contain', + isWholeLine: true, + overviewRulerLane: OverviewRulerLane.Right, + backgroundColor: cfgHighlight.locations.includes(HighlightLocations.Line) + ? new ThemeColor('gitlens.lineHighlightBackgroundColor') + : undefined, + overviewRulerColor: cfgHighlight.locations.includes(HighlightLocations.Overview) + ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor') + : undefined, + dark: { + gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) + ? Container.context.asAbsolutePath('images/dark/highlight-gutter.svg') + : undefined + }, + light: { + gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) + ? Container.context.asAbsolutePath('images/light/highlight-gutter.svg') + : undefined + } + }); + } + else { + Decorations.blameHighlight = undefined; + } + } + + if (initializing || configuration.changed(e, configuration.name('recentChanges')('highlight').value)) { + Decorations.recentChangesAnnotation && Decorations.recentChangesAnnotation.dispose(); + + const cfgHighlight = cfg.recentChanges.highlight; + + Decorations.recentChangesAnnotation = window.createTextEditorDecorationType({ + gutterIconSize: 'contain', + isWholeLine: true, + overviewRulerLane: OverviewRulerLane.Right, + backgroundColor: cfgHighlight.locations.includes(HighlightLocations.Line) + ? new ThemeColor('gitlens.lineHighlightBackgroundColor') + : undefined, + overviewRulerColor: cfgHighlight.locations.includes(HighlightLocations.Overview) + ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor') + : undefined, + dark: { + gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) + ? Container.context.asAbsolutePath('images/dark/highlight-gutter.svg') + : undefined + }, + light: { + gutterIconPath: cfgHighlight.locations.includes(HighlightLocations.Gutter) + ? Container.context.asAbsolutePath('images/light/highlight-gutter.svg') + : undefined + } + }); + } + + if (initializing || configuration.changed(e, configuration.name('blame')('toggleMode').value)) { + this._toggleModes.set(FileAnnotationType.Blame, cfg.blame.toggleMode); + if (!initializing && cfg.blame.toggleMode === AnnotationsToggleMode.File) { + this.clearAll(); + } + } + + if (initializing || configuration.changed(e, configuration.name('heatmap')('toggleMode').value)) { + this._toggleModes.set(FileAnnotationType.Heatmap, cfg.heatmap.toggleMode); + if (!initializing && cfg.heatmap.toggleMode === AnnotationsToggleMode.File) { + this.clearAll(); + } + } + + if (initializing || configuration.changed(e, configuration.name('recentChanges')('toggleMode').value)) { + this._toggleModes.set(FileAnnotationType.RecentChanges, cfg.recentChanges.toggleMode); + if (!initializing && cfg.recentChanges.toggleMode === AnnotationsToggleMode.File) { + this.clearAll(); + } + } + + if (initializing) return; + + if (configuration.changed(e, configuration.name('blame').value) || + configuration.changed(e, configuration.name('recentChanges').value) || + configuration.changed(e, configuration.name('hovers').value)) { + // Since the configuration has changed -- reset any visible annotations + for (const provider of this._annotationProviders.values()) { + if (provider === undefined) continue; + + if (provider.annotationType === FileAnnotationType.RecentChanges) { + provider.reset({ decoration: Decorations.recentChangesAnnotation!, highlightDecoration: Decorations.recentChangesHighlight }); + } + else if (provider.annotationType === FileAnnotationType.Blame) { + provider.reset({ decoration: Decorations.blameAnnotation, highlightDecoration: Decorations.blameHighlight }); + } + else { + this.show(provider.editor, FileAnnotationType.Heatmap); + } + } + } + } + + private async onActiveTextEditorChanged(editor: TextEditor | undefined) { + if (editor !== undefined && !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 === undefined) { + setCommandContext(CommandContext.AnnotationStatus, undefined); + this.detachKeyboardHook(); + } + else { + setCommandContext(CommandContext.AnnotationStatus, provider.status); + this.attachKeyboardHook(); + } + } + + private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + // Only care if we are becoming un-blameable + if (e.blameable) return; + + const editor = window.activeTextEditor; + if (editor === undefined) return; + + this.clear(editor, AnnotationClearReason.BlameabilityChanged); + } + + private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + for (const [key, p] of this._annotationProviders) { + if (!e.document.is(p.document)) continue; + + 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; + + 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 === undefined) { + // 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; + + this.clearCore(fuzzyProvider.correlationKey, AnnotationClearReason.ColumnChanged); + + return; + } + + provider.restore(e.textEditor); + } + + private onVisibleTextEditorsChanged(editors: TextEditor[]) { + let provider: AnnotationProviderBase | undefined; + for (const e of editors) { + provider = this.getProvider(e); + if (provider === undefined) continue; + + provider.restore(e); + } + } + + isInWindowToggle(): boolean { + return this.getToggleMode(this._annotationType) === AnnotationsToggleMode.Window; + } + + private getToggleMode(annotationType: FileAnnotationType | undefined): AnnotationsToggleMode { + if (annotationType === undefined) 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 { + const provider = this.getProvider(editor); + if (provider === undefined) return undefined; + + const trackedDocument = await Container.tracker.get(editor!.document); + if (trackedDocument === undefined || !trackedDocument.isBlameable) return undefined; + + return provider.annotationType; + } + + getProvider(editor: TextEditor | undefined): AnnotationProviderBase | undefined { + if (editor === undefined || editor.document === undefined) return undefined; + return this._annotationProviders.get(AnnotationProviderBase.getCorrelationKey(editor)); + } + + async show(editor: TextEditor | undefined, type: FileAnnotationType, shaOrLine?: string | number): Promise { + if (this.getToggleMode(type) === AnnotationsToggleMode.Window) { + let first = this._annotationType === undefined; + const reset = !first && this._annotationType !== type; + + this._annotationType = type; + + if (reset) { + await this.clearAll(); + first = true; + } + + if (first) { + for (const e of window.visibleTextEditors) { + if (e === editor) continue; + + this.show(e, type); + } + } + } + + if (editor === undefined) return false; // || editor.viewColumn === undefined) 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 !== undefined && currentProvider.annotationType === type) { + await currentProvider.selection(shaOrLine); + return true; + } + + const provider = await window.withProgress({ location: ProgressLocation.Window }, async (progress: Progress<{ message: string }>) => { + await setCommandContext(CommandContext.AnnotationStatus, AnnotationStatus.Computing); + + const computingAnnotations = this.showAnnotationsCore(currentProvider, editor, type, shaOrLine, progress); + const provider = await computingAnnotations; + + if (editor === this._editor) { + await setCommandContext(CommandContext.AnnotationStatus, provider && provider.status); + } + + return computingAnnotations; + }); + + return provider !== undefined; + } + + async toggle(editor: TextEditor | undefined, type: FileAnnotationType, shaOrLine?: string | number): Promise { + if (editor !== undefined) { + const trackedDocument = await Container.tracker.getOrAdd(editor.document); + if ((type === FileAnnotationType.RecentChanges && !trackedDocument.isTracked) || !trackedDocument.isBlameable) return false; + } + + const provider = this.getProvider(editor); + if (provider === undefined) return this.show(editor!, type, shaOrLine); + + const reopen = provider.annotationType !== type; + + 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 === undefined) { + this._keyboardScope = await Container.keyboard.beginScope({ + escape: { + onDidPressKey: async (key: Keys) => { + const e = this._editor; + if (e === undefined) return undefined; + + await this.clear(e, AnnotationClearReason.User); + return undefined; + } + } as KeyCommand + }); + } + } + + private async clearCore(key: TextEditorCorrelationKey, reason: AnnotationClearReason) { + const provider = this._annotationProviders.get(key); + if (provider === undefined) return; + + Logger.log(`${reason}:`, `Clear annotations for ${key}`); + + this._annotationProviders.delete(key); + await provider.dispose(); + + if (this._annotationProviders.size === 0 || key === AnnotationProviderBase.getCorrelationKey(this._editor)) { + await setCommandContext(CommandContext.AnnotationStatus, undefined); + await this.detachKeyboardHook(); + } + + if (this._annotationProviders.size === 0) { + Logger.log(`Remove all listener registrations for annotations`); + + this._annotationsDisposable && this._annotationsDisposable.dispose(); + this._annotationsDisposable = undefined; + } + + this._onDidToggleAnnotations.fire(); + } + + private async detachKeyboardHook() { + if (this._keyboardScope === undefined) 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 { + if (progress !== undefined) { + let annotationsLabel = 'annotations'; + switch (type) { + case FileAnnotationType.Blame: + annotationsLabel = 'blame annotations'; + break; + + case FileAnnotationType.Heatmap: + annotationsLabel = 'heatmap annotations'; + break; + + case FileAnnotationType.RecentChanges: + annotationsLabel = 'recent changes annotations'; + break; + } + + progress!.report({ message: `Computing ${annotationsLabel} for ${path.basename(editor.document.fileName)}` }); + } + + // Allows pressing escape to exit the annotations + 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, Decorations.blameAnnotation, Decorations.blameHighlight); + break; + + case FileAnnotationType.Heatmap: + provider = new HeatmapBlameAnnotationProvider(editor, trackedDocument, Decorations.heatmapAnnotation, Decorations.heatmapHighlight); + break; + + case FileAnnotationType.RecentChanges: + provider = new RecentChangesAnnotationProvider(editor, trackedDocument, Decorations.recentChangesAnnotation!, Decorations.recentChangesHighlight); + break; + } + if (provider === undefined || !(await provider.validate())) return undefined; + + if (currentProvider !== undefined) { + await this.clearCore(currentProvider.correlationKey, AnnotationClearReason.User); + } + + if (!this._annotationsDisposable && 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; + } + + return undefined; + } +} \ No newline at end of file diff --git a/src/annotations/lineAnnotationController.ts b/src/annotations/lineAnnotationController.ts new file mode 100644 index 0000000..fa5daaf --- /dev/null +++ b/src/annotations/lineAnnotationController.ts @@ -0,0 +1,202 @@ +'use strict'; +import { ConfigurationChangeEvent, debug, DecorationRangeBehavior, DecorationRenderOptions, Disposable, Range, TextEditor, TextEditorDecorationType, window } from 'vscode'; +import { Annotations } from './annotations'; +import { configuration, IConfig } from './../configuration'; +import { isTextEditor, RangeEndOfLineIndex } from './../constants'; +import { Container } from './../container'; +import { LinesChangeEvent } from './../trackers/gitLineTracker'; + +const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ + after: { + margin: '0 0 0 3em', + textDecoration: 'none' + }, + rangeBehavior: DecorationRangeBehavior.ClosedOpen +} as DecorationRenderOptions); + +export class LineAnnotationController extends Disposable { + + private _disposable: Disposable; + private _debugSessionEndDisposable: Disposable | undefined; + private _editor: TextEditor | undefined; + private _enabled: boolean = false; + + constructor() { + super(() => this.dispose()); + + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + Container.fileAnnotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this), + debug.onDidStartDebugSession(this.onDebugSessionStarted, this) + ); + this.onConfigurationChanged(configuration.initializingChangeEvent); + } + + dispose() { + this.clearAnnotations(this._editor); + + this._debugSessionEndDisposable && this._debugSessionEndDisposable.dispose(); + + Container.lineTracker.stop(this); + this._disposable && this._disposable.dispose(); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + const initializing = configuration.initializing(e); + + if (!initializing && !configuration.changed(e, configuration.name('currentLine').value)) return; + + if (initializing || configuration.changed(e, configuration.name('currentLine')('enabled').value)) { + const cfg = configuration.get(); + if (cfg.currentLine.enabled) { + this._enabled = true; + + Container.lineTracker.start( + this, + Disposable.from( + Container.lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this) + ) + ); + } + else { + this._enabled = false; + + Container.lineTracker.stop(this); + } + } + + this.refresh(window.activeTextEditor); + } + + private _suspended?: 'debugging' | 'user'; + get suspended() { + return !this._enabled || this._suspended !== undefined; + } + + resume(reason: 'debugging' | 'user' = 'user') { + switch (reason) { + case 'debugging': + if (this._suspended !== 'user') { + this._suspended = undefined; + return true; + } + break; + + case 'user': + if (this._suspended !== undefined) { + this._suspended = undefined; + return true; + } + break; + } + + return false; + } + + suspend(reason: 'debugging' | 'user' = 'user') { + if (this._suspended !== 'user') { + this._suspended = reason; + return true; + } + + return false; + } + + private onActiveLinesChanged(e: LinesChangeEvent) { + if (!e.pending && e.lines !== undefined) { + this.refresh(e.editor); + + return; + } + + this.clear(e.editor); + } + + private onDebugSessionStarted() { + if (this._debugSessionEndDisposable === undefined) { + this._debugSessionEndDisposable = debug.onDidTerminateDebugSession(this.onDebugSessionEnded, this); + } + + if (this.suspend('debugging')) { + this.refresh(window.activeTextEditor); + } + } + + private onDebugSessionEnded() { + if (this._debugSessionEndDisposable !== undefined) { + this._debugSessionEndDisposable.dispose(); + this._debugSessionEndDisposable = undefined; + } + + if (this.resume('debugging')) { + this.refresh(window.activeTextEditor); + } + } + + private onFileAnnotationsToggled() { + this.refresh(window.activeTextEditor); + } + + async clear(editor: TextEditor | undefined) { + if (this._editor !== editor && this._editor !== undefined) { + this.clearAnnotations(this._editor); + } + this.clearAnnotations(editor); + } + + async toggle(editor: TextEditor | undefined) { + this._enabled = !(this._enabled && !this.suspended); + + if (this._enabled) { + if (this.resume('user')) { + await this.refresh(editor); + } + } + else { + if (this.suspend('user')) { + await this.refresh(editor); + } + } + } + + private clearAnnotations(editor: TextEditor | undefined) { + if (editor === undefined || (editor as any)._disposed === true) return; + + editor.setDecorations(annotationDecoration, []); + } + + private async refresh(editor: TextEditor | undefined) { + if (editor === undefined && this._editor === undefined) return; + + const lines = Container.lineTracker.lines; + if (editor === undefined || lines === undefined || !isTextEditor(editor)) return this.clear(this._editor); + + if (this._editor !== editor) { + // Clear any annotations on the previously active editor + this.clear(this._editor); + + this._editor = editor; + } + + const cfg = Container.config.currentLine; + if (this.suspended) return this.clear(editor); + + const trackedDocument = await Container.tracker.getOrAdd(editor.document); + if (!trackedDocument.isBlameable && this.suspended) return this.clear(editor); + + // Make sure the editor hasn't died since the await above and that we are still on the same line(s) + if (editor.document === undefined || !Container.lineTracker.includesAll(lines)) return; + + const decorations = []; + for (const l of lines) { + const state = Container.lineTracker.getState(l); + if (state === undefined || state.commit === undefined) continue; + + const decoration = Annotations.trailing(state.commit, cfg.format, cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat); + decoration.range = editor.document.validateRange(new Range(l, RangeEndOfLineIndex, l, RangeEndOfLineIndex)); + decorations.push(decoration); + } + + editor.setDecorations(annotationDecoration, decorations); + } +} \ No newline at end of file diff --git a/src/annotations/lineHoverController.ts b/src/annotations/lineHoverController.ts new file mode 100644 index 0000000..12a0826 --- /dev/null +++ b/src/annotations/lineHoverController.ts @@ -0,0 +1,180 @@ +'use strict'; +import { CancellationToken, ConfigurationChangeEvent, debug, Disposable, Hover, HoverProvider, languages, Position, Range, TextDocument, TextEditor, window } from 'vscode'; +import { Annotations } from './annotations'; +import { configuration, IConfig } from './../configuration'; +import { RangeEndOfLineIndex } from './../constants'; +import { Container } from './../container'; +import { LinesChangeEvent } from './../trackers/gitLineTracker'; + +export class LineHoverController extends Disposable { + + private _debugSessionEndDisposable: Disposable | undefined; + private _disposable: Disposable; + private _hoverProviderDisposable: Disposable | undefined; + + constructor() { + super(() => this.dispose()); + + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this), + debug.onDidStartDebugSession(this.onDebugSessionStarted, this) + ); + this.onConfigurationChanged(configuration.initializingChangeEvent); + } + + dispose() { + this.unregister(); + + this._debugSessionEndDisposable && this._debugSessionEndDisposable.dispose(); + + Container.lineTracker.stop(this); + this._disposable && this._disposable.dispose(); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + const initializing = configuration.initializing(e); + + if (!initializing && + !configuration.changed(e, configuration.name('hovers')('enabled').value) && + !configuration.changed(e, configuration.name('hovers')('currentLine')('enabled').value)) return; + + const cfg = configuration.get(); + + if (cfg.hovers.enabled && cfg.hovers.currentLine.enabled) { + Container.lineTracker.start( + this, + Disposable.from(Container.lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this)) + ); + + this.register(window.activeTextEditor); + } + else { + Container.lineTracker.stop(this); + this.unregister(); + } + } + + private get debugging() { + return this._debugSessionEndDisposable !== undefined; + } + + private onActiveLinesChanged(e: LinesChangeEvent) { + if (e.pending || e.reason !== 'editor') return; + + if (e.editor === undefined || e.lines === undefined) { + this.unregister(); + + return; + } + + this.register(e.editor); + } + + private onDebugSessionStarted() { + if (this._debugSessionEndDisposable === undefined) { + this._debugSessionEndDisposable = debug.onDidTerminateDebugSession(this.onDebugSessionEnded, this); + } + } + + private onDebugSessionEnded() { + if (this._debugSessionEndDisposable !== undefined) { + this._debugSessionEndDisposable.dispose(); + this._debugSessionEndDisposable = undefined; + } + } + + async provideDetailsHover(document: TextDocument, position: Position, token: CancellationToken): Promise { + if (!Container.lineTracker.includes(position.line)) return undefined; + + const lineState = Container.lineTracker.getState(position.line); + const commit = lineState !== undefined ? lineState.commit : undefined; + if (commit === undefined) return undefined; + + // Avoid double annotations if we are showing the whole-file hover blame annotations + const fileAnnotations = await Container.fileAnnotations.getAnnotationType(window.activeTextEditor); + if (fileAnnotations !== undefined && Container.config.hovers.annotations.details) return undefined; + + const wholeLine = this.debugging ? false : Container.config.hovers.currentLine.over === 'line'; + // If we aren't showing the hover over the whole line, make sure the annotation is on + if (!wholeLine && Container.lineAnnotations.suspended) return undefined; + + const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex)); + if (!wholeLine && range.start.character !== position.character) return undefined; + + // Get the full commit message -- since blame only returns the summary + let logCommit = lineState !== undefined ? lineState.logCommit : undefined; + if (logCommit === undefined && !commit.isUncommitted) { + logCommit = await Container.git.getLogCommitForFile(commit.repoPath, commit.uri.fsPath, { ref: commit.sha }); + if (logCommit !== undefined) { + // Preserve the previous commit from the blame commit + logCommit.previousSha = commit.previousSha; + logCommit.previousFileName = commit.previousFileName; + + if (lineState !== undefined) { + lineState.logCommit = logCommit; + } + } + } + + const trackedDocument = await Container.tracker.get(document); + if (trackedDocument === undefined) return undefined; + + const message = Annotations.getHoverMessage(logCommit || commit, Container.config.defaultDateFormat, await Container.git.getRemotes(commit.repoPath), fileAnnotations, position.line); + return new Hover(message, range); + } + + async provideChangesHover(document: TextDocument, position: Position, token: CancellationToken): Promise { + if (!Container.lineTracker.includes(position.line)) return undefined; + + const lineState = Container.lineTracker.getState(position.line); + const commit = lineState !== undefined ? lineState.commit : undefined; + if (commit === undefined) return undefined; + + // Avoid double annotations if we are showing the whole-file hover blame annotations + if (Container.config.hovers.annotations.changes) { + const fileAnnotations = await Container.fileAnnotations.getAnnotationType(window.activeTextEditor); + if (fileAnnotations !== undefined) return undefined; + } + + const wholeLine = this.debugging ? false : Container.config.hovers.currentLine.over === 'line'; + // If we aren't showing the hover over the whole line, make sure the annotation is on + if (!wholeLine && Container.lineAnnotations.suspended) return undefined; + + const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex)); + if (!wholeLine && range.start.character !== position.character) return undefined; + + const trackedDocument = await Container.tracker.get(document); + if (trackedDocument === undefined) return undefined; + + const hover = await Annotations.changesHover(commit, position.line, trackedDocument.uri); + if (hover.hoverMessage === undefined) return undefined; + + return new Hover(hover.hoverMessage, range); + } + + private register(editor: TextEditor | undefined) { + this.unregister(); + + if (editor === undefined /* || this.suspended */) return; + + const cfg = Container.config.hovers; + if (!cfg.enabled || !cfg.currentLine.enabled || (!cfg.currentLine.details && !cfg.currentLine.changes)) return; + + const subscriptions = []; + if (cfg.currentLine.changes) { + subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideChangesHover.bind(this) } as HoverProvider)); + } + if (cfg.currentLine.details) { + subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideDetailsHover.bind(this) } as HoverProvider)); + } + + this._hoverProviderDisposable = Disposable.from(...subscriptions); + } + + private unregister() { + if (this._hoverProviderDisposable !== undefined) { + this._hoverProviderDisposable.dispose(); + this._hoverProviderDisposable = undefined; + } + } +} \ No newline at end of file diff --git a/src/commands.ts b/src/commands.ts index 76c5fc3..e771f6b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -30,9 +30,7 @@ export * from './commands/openRepoInRemote'; export * from './commands/openWorkingFile'; export * from './commands/resetSuppressedWarnings'; export * from './commands/showCommitSearch'; -export * from './commands/showFileBlame'; export * from './commands/showLastQuickPick'; -export * from './commands/showLineBlame'; export * from './commands/showQuickBranchHistory'; export * from './commands/showQuickCommitDetails'; export * from './commands/showQuickCommitFileDetails'; @@ -78,8 +76,6 @@ export function configureCommands(): void { Container.context.subscriptions.push(new Commands.OpenRepoInRemoteCommand()); Container.context.subscriptions.push(new Commands.OpenWorkingFileCommand()); Container.context.subscriptions.push(new Commands.ClearFileAnnotationsCommand()); - Container.context.subscriptions.push(new Commands.ShowFileBlameCommand()); - Container.context.subscriptions.push(new Commands.ShowLineBlameCommand()); Container.context.subscriptions.push(new Commands.ToggleFileBlameCommand()); Container.context.subscriptions.push(new Commands.ToggleFileHeatmapCommand()); Container.context.subscriptions.push(new Commands.ToggleFileRecentChangesCommand()); diff --git a/src/commands/clearFileAnnotations.ts b/src/commands/clearFileAnnotations.ts index 3dadd31..d3fa4f0 100644 --- a/src/commands/clearFileAnnotations.ts +++ b/src/commands/clearFileAnnotations.ts @@ -23,7 +23,7 @@ export class ClearFileAnnotationsCommand extends EditorCommand { } try { - return Container.annotations.clear(editor); + return Container.fileAnnotations.clear(editor); } catch (ex) { Logger.error(ex, 'ClearFileAnnotationsCommand'); diff --git a/src/commands/common.ts b/src/commands/common.ts index 23e2d07..6757137 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -40,9 +40,7 @@ export enum Commands { OpenWorkingFile = 'gitlens.openWorkingFile', ResetSuppressedWarnings = 'gitlens.resetSuppressedWarnings', ShowCommitSearch = 'gitlens.showCommitSearch', - ShowFileBlame = 'gitlens.showFileBlame', ShowLastQuickPick = 'gitlens.showLastQuickPick', - ShowLineBlame = 'gitlens.showLineBlame', ShowQuickCommitDetails = 'gitlens.showQuickCommitDetails', ShowQuickCommitFileDetails = 'gitlens.showQuickCommitFileDetails', ShowQuickFileHistory = 'gitlens.showQuickFileHistory', @@ -307,7 +305,7 @@ export async function openEditor(uri: Uri, options: TextDocumentShowOptions & { } // TODO: revist this - // This is a bit of an ugly hack, but I added it because there a bunch of call sites and toRevisionUri isn't async (and can't be easily made async because of use in ctors) + // This is a bit of an ugly hack, but I added it because there a bunch of call sites and toRevisionUri can't be easily made async because of its use in ctors if (uri.scheme === DocumentSchemes.GitLensGit) { const gitUri = GitUri.fromRevisionUri(uri); if (ImageExtensions.includes(path.extname(gitUri.fsPath))) { diff --git a/src/commands/openFileRevision.ts b/src/commands/openFileRevision.ts index 15f6019..bac88a5 100644 --- a/src/commands/openFileRevision.ts +++ b/src/commands/openFileRevision.ts @@ -130,7 +130,7 @@ export class OpenFileRevisionCommand extends ActiveEditorCommand { const e = await openEditor(args.uri!, { ...args.showOptions, rethrow: true }); if (args.annotationType === undefined) return e; - return Container.annotations.showAnnotations(e!, args.annotationType, args.line); + return Container.fileAnnotations.show(e!, args.annotationType, args.line); } catch (ex) { Logger.error(ex, 'OpenFileRevisionCommand'); diff --git a/src/commands/openWorkingFile.ts b/src/commands/openWorkingFile.ts index 46155c6..84a92a7 100644 --- a/src/commands/openWorkingFile.ts +++ b/src/commands/openWorkingFile.ts @@ -50,7 +50,7 @@ export class OpenWorkingFileCommand extends ActiveEditorCommand { const e = await openEditor(args.uri, { ...args.showOptions, rethrow: true }); if (args.annotationType === undefined) return e; - return Container.annotations.showAnnotations(e!, args.annotationType, args.line); + return Container.fileAnnotations.show(e!, args.annotationType, args.line); } catch (ex) { Logger.error(ex, 'OpenWorkingFileCommand'); diff --git a/src/commands/showFileBlame.ts b/src/commands/showFileBlame.ts deleted file mode 100644 index b3dc4b11..0000000 --- a/src/commands/showFileBlame.ts +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; -import { TextEditor, TextEditorEdit, Uri, window } from 'vscode'; -import { Commands, EditorCommand } from './common'; -import { FileAnnotationType } from '../configuration'; -import { Container } from '../container'; -import { Logger } from '../logger'; - -export interface ShowFileBlameCommandArgs { - sha?: string; - type?: FileAnnotationType; -} - -export class ShowFileBlameCommand extends EditorCommand { - - constructor() { - super(Commands.ShowFileBlame); - } - - async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ShowFileBlameCommandArgs = {}): Promise { - if (editor === undefined) return undefined; - - try { - if (args.type === undefined) { - args = { ...args, type: FileAnnotationType.Blame }; - } - - return Container.annotations.showAnnotations(editor, args.type!, args.sha !== undefined ? args.sha : editor.selection.active.line); - } - catch (ex) { - Logger.error(ex, 'ShowFileBlameCommand'); - return window.showErrorMessage(`Unable to show file blame annotations. See output channel for more details`); - } - } -} \ No newline at end of file diff --git a/src/commands/showLineBlame.ts b/src/commands/showLineBlame.ts deleted file mode 100644 index eabca20..0000000 --- a/src/commands/showLineBlame.ts +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; -import { TextEditor, Uri, window } from 'vscode'; -import { ActiveEditorCommand, Commands } from './common'; -import { Container } from '../container'; -import { Logger } from '../logger'; - -export class ShowLineBlameCommand extends ActiveEditorCommand { - - constructor() { - super(Commands.ShowLineBlame); - } - - async execute(editor?: TextEditor, uri?: Uri): Promise { - try { - return Container.lineAnnotations.showAnnotations(editor); - } - catch (ex) { - Logger.error(ex, 'ShowLineBlameCommand'); - return window.showErrorMessage(`Unable to show line blame annotations. See output channel for more details`); - } - } -} \ No newline at end of file diff --git a/src/commands/toggleFileBlame.ts b/src/commands/toggleFileBlame.ts index e498d07..2dabf20 100644 --- a/src/commands/toggleFileBlame.ts +++ b/src/commands/toggleFileBlame.ts @@ -35,7 +35,7 @@ export class ToggleFileBlameCommand extends ActiveEditorCommand { args = { ...args, type: FileAnnotationType.Blame }; } - return Container.annotations.toggleAnnotations(editor, args.type!, args.sha !== undefined ? args.sha : editor && editor.selection.active.line); + return Container.fileAnnotations.toggle(editor, args.type!, args.sha !== undefined ? args.sha : editor && editor.selection.active.line); } catch (ex) { Logger.error(ex, 'ToggleFileBlameCommand'); diff --git a/src/commands/toggleLineBlame.ts b/src/commands/toggleLineBlame.ts index e15e28c..3b826bc 100644 --- a/src/commands/toggleLineBlame.ts +++ b/src/commands/toggleLineBlame.ts @@ -12,7 +12,7 @@ export class ToggleLineBlameCommand extends ActiveEditorCommand { async execute(editor: TextEditor, uri?: Uri): Promise { try { - return Container.lineAnnotations.toggleAnnotations(editor); + return Container.lineAnnotations.toggle(editor); } catch (ex) { Logger.error(ex, 'ToggleLineBlameCommand'); diff --git a/src/container.ts b/src/container.ts index 07abb43..535a126 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,9 +1,10 @@ 'use strict'; import { Disposable, ExtensionContext, languages, workspace } from 'vscode'; -import { AnnotationController } from './annotations/annotationController'; +import { FileAnnotationController } from './annotations/fileAnnotationController'; import { CodeLensController } from './codeLensController'; import { configuration, IConfig } from './configuration'; -import { CurrentLineController } from './currentLineController'; +import { LineAnnotationController } from './annotations/lineAnnotationController'; +import { LineHoverController } from './annotations/lineHoverController'; import { ExplorerCommands } from './views/explorerCommands'; import { GitContentProvider } from './gitContentProvider'; import { GitDocumentTracker } from './trackers/gitDocumentTracker'; @@ -14,6 +15,7 @@ import { GitService } from './gitService'; import { Keyboard } from './keyboard'; import { PageProvider } from './pageProvider'; import { ResultsExplorer } from './views/resultsExplorer'; +import { StatusBarController } from './statusBarController'; export class Container { @@ -28,8 +30,10 @@ export class Container { // Since there is a bit of a chicken & egg problem with the DocumentTracker and the GitService, initialize the tracker once the GitService is loaded this._tracker.initialize(); - context.subscriptions.push(this._annotationController = new AnnotationController()); - context.subscriptions.push(this._currentLineController = new CurrentLineController()); + context.subscriptions.push(this._fileAnnotationController = new FileAnnotationController()); + context.subscriptions.push(this._lineAnnotationController = new LineAnnotationController()); + context.subscriptions.push(this._lineHoverController = new LineHoverController()); + context.subscriptions.push(this._statusBarController = new StatusBarController()); context.subscriptions.push(this._codeLensController = new CodeLensController()); context.subscriptions.push(this._keyboard = new Keyboard()); context.subscriptions.push(this._pageProvider = new PageProvider()); @@ -51,11 +55,6 @@ export class Container { context.subscriptions.push(languages.registerCodeLensProvider(GitRevisionCodeLensProvider.selector, new GitRevisionCodeLensProvider())); } - private static _annotationController: AnnotationController; - static get annotations() { - return this._annotationController; - } - private static _codeLensController: CodeLensController; static get codeLens() { return this._codeLensController; @@ -82,6 +81,11 @@ export class Container { return this._explorerCommands; } + private static _fileAnnotationController: FileAnnotationController; + static get fileAnnotations() { + return this._fileAnnotationController; + } + private static _git: GitService; static get git() { return this._git; @@ -97,9 +101,14 @@ export class Container { return this._keyboard; } - private static _currentLineController: CurrentLineController; + private static _lineAnnotationController: LineAnnotationController; static get lineAnnotations() { - return this._currentLineController; + return this._lineAnnotationController; + } + + private static _lineHoverController: LineHoverController; + static get lineHovers() { + return this._lineHoverController; } private static _lineTracker: GitLineTracker; @@ -121,6 +130,11 @@ export class Container { return this._resultsExplorer; } + private static _statusBarController: StatusBarController; + static get statusBar() { + return this._statusBarController; + } + private static _tracker: GitDocumentTracker; static get tracker() { return this._tracker; diff --git a/src/currentLineController.ts b/src/currentLineController.ts deleted file mode 100644 index c116ed4..0000000 --- a/src/currentLineController.ts +++ /dev/null @@ -1,539 +0,0 @@ -'use strict'; -import { Functions, IDeferrable } from './system'; -import { CancellationToken, ConfigurationChangeEvent, debug, DecorationRangeBehavior, DecorationRenderOptions, Disposable, Hover, HoverProvider, languages, Position, Range, StatusBarAlignment, StatusBarItem, TextDocument, TextEditor, TextEditorDecorationType, window } from 'vscode'; -import { Annotations } from './annotations/annotations'; -import { Commands } from './commands'; -import { configuration, IConfig, StatusBarCommand } from './configuration'; -import { isTextEditor, RangeEndOfLineIndex } from './constants'; -import { Container } from './container'; -import { DocumentBlameStateChangeEvent, DocumentDirtyIdleTriggerEvent, DocumentDirtyStateChangeEvent, GitDocumentState, TrackedDocument } from './trackers/gitDocumentTracker'; -import { GitLineState, GitLineTracker, LinesChangeEvent } from './trackers/gitLineTracker'; -import { CommitFormatter, GitBlameLine, GitCommit, ICommitFormatOptions } from './gitService'; - -const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ - after: { - margin: '0 0 0 3em', - textDecoration: 'none' - }, - rangeBehavior: DecorationRangeBehavior.ClosedOpen -} as DecorationRenderOptions); - -class AnnotationState { - - constructor(private _enabled: boolean) { } - - get enabled(): boolean { - return this.suspended ? false : this._enabled; - } - - private _suspendReason?: 'debugging' | 'dirty'; - get suspended(): boolean { - return this._suspendReason !== undefined; - } - - reset(enabled: boolean): boolean { - // returns whether a refresh is required - - if (this._enabled === enabled && !this.suspended) return false; - - this._enabled = enabled; - this._suspendReason = undefined; - - return true; - } - - resume(reason: 'debugging' | 'dirty'): boolean { - // returns whether a refresh is required - - const refresh = this._suspendReason !== undefined; - this._suspendReason = undefined; - return refresh; - } - - suspend(reason: 'debugging' | 'dirty'): boolean { - // returns whether a refresh is required - - const refresh = this._suspendReason === undefined; - this._suspendReason = reason; - return refresh; - } -} - -export class CurrentLineController extends Disposable { - - private _blameAnnotationState: AnnotationState | undefined; - private _editor: TextEditor | undefined; - private _lineTracker: GitLineTracker; - private _statusBarItem: StatusBarItem | undefined; - - private _disposable: Disposable; - private _debugSessionEndDisposable: Disposable | undefined; - private _hoverProviderDisposable: Disposable | undefined; - private _lineTrackingDisposable: Disposable | undefined; - - constructor() { - super(() => this.dispose()); - - this._lineTracker = Container.lineTracker; - - this._disposable = Disposable.from( - this._lineTracker, - configuration.onDidChange(this.onConfigurationChanged, this), - Container.annotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this), - debug.onDidStartDebugSession(this.onDebugSessionStarted, this) - ); - this.onConfigurationChanged(configuration.initializingChangeEvent); - } - - dispose() { - this.clearAnnotations(this._editor); - this.unregisterHoverProviders(); - - this._debugSessionEndDisposable && this._debugSessionEndDisposable.dispose(); - this._lineTrackingDisposable && this._lineTrackingDisposable.dispose(); - this._statusBarItem && this._statusBarItem.dispose(); - - this._disposable && this._disposable.dispose(); - } - - private onConfigurationChanged(e: ConfigurationChangeEvent) { - const initializing = configuration.initializing(e); - - const cfg = configuration.get(); - - let changed = false; - - if (initializing || configuration.changed(e, configuration.name('currentLine').value)) { - changed = true; - this._blameAnnotationState = undefined; - } - - if (initializing || configuration.changed(e, configuration.name('hovers').value)) { - changed = true; - this.unregisterHoverProviders(); - } - - if (initializing || configuration.changed(e, configuration.name('statusBar').value)) { - changed = true; - - if (cfg.statusBar.enabled) { - const alignment = cfg.statusBar.alignment !== 'left' ? StatusBarAlignment.Right : StatusBarAlignment.Left; - if (this._statusBarItem !== undefined && this._statusBarItem.alignment !== alignment) { - this._statusBarItem.dispose(); - this._statusBarItem = undefined; - } - - this._statusBarItem = this._statusBarItem || window.createStatusBarItem(alignment, alignment === StatusBarAlignment.Right ? 1000 : 0); - this._statusBarItem.command = cfg.statusBar.command; - } - else if (this._statusBarItem !== undefined) { - this._statusBarItem.dispose(); - this._statusBarItem = undefined; - } - } - - if (!changed) return; - - const trackCurrentLine = cfg.currentLine.enabled || cfg.statusBar.enabled || (cfg.hovers.enabled && cfg.hovers.currentLine.enabled) || - (this._blameAnnotationState !== undefined && this._blameAnnotationState.enabled); - - if (trackCurrentLine) { - this._lineTracker.start(); - - this._lineTrackingDisposable = this._lineTrackingDisposable || Disposable.from( - this._lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this), - Container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), - Container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), - Container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this) - ); - } - else { - this._lineTracker.stop(); - - if (this._lineTrackingDisposable !== undefined) { - this._lineTrackingDisposable.dispose(); - this._lineTrackingDisposable = undefined; - } - } - - this.refresh(window.activeTextEditor, { full: true }); - } - - private onActiveLinesChanged(e: LinesChangeEvent) { - if (!e.pending && e.lines !== undefined) { - this.refresh(e.editor); - - return; - } - - this.clear(e.editor, (Container.config.statusBar.reduceFlicker && e.reason === 'lines' && e.lines !== undefined) ? 'lines' : undefined); - } - - private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { - if (e.blameable) { - this.refresh(e.editor); - - return; - } - - this.clear(e.editor); - } - - private onDebugSessionStarted() { - if (this.suspendBlameAnnotations('debugging', window.activeTextEditor)) { - this._debugSessionEndDisposable = debug.onDidTerminateDebugSession(this.onDebugSessionEnded, this); - } - } - - private onDebugSessionEnded() { - if (this._debugSessionEndDisposable !== undefined) { - this._debugSessionEndDisposable.dispose(); - this._debugSessionEndDisposable = undefined; - } - - this.resumeBlameAnnotations('debugging', window.activeTextEditor); - } - - private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { - const maxLines = configuration.get(configuration.name('advanced')('blame')('sizeThresholdAfterEdit').value); - if (maxLines > 0 && e.document.lineCount > maxLines) return; - - this.resumeBlameAnnotations('dirty', window.activeTextEditor); - } - - private async onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { - if (e.dirty) { - this.suspendBlameAnnotations('dirty', window.activeTextEditor); - } - else { - this.resumeBlameAnnotations('dirty', window.activeTextEditor, { force: true }); - } - } - - private onFileAnnotationsToggled() { - this.refresh(window.activeTextEditor); - } - - async clear(editor: TextEditor | undefined, reason?: 'lines') { - if (this._editor !== editor && this._editor !== undefined) { - this.clearAnnotations(this._editor); - } - this.clearAnnotations(editor); - this.unregisterHoverProviders(); - - this._lineTracker.reset(); - - if (this._statusBarItem !== undefined && reason !== 'lines') { - this._statusBarItem.hide(); - } - } - - async provideDetailsHover(document: TextDocument, position: Position, token: CancellationToken): Promise { - if (this._editor === undefined || this._editor.document !== document || !this._lineTracker.includes(position.line)) return undefined; - - const lineState = this._lineTracker.getState(position.line); - const commit = lineState !== undefined ? lineState.commit : undefined; - if (commit === undefined) return undefined; - - // Avoid double annotations if we are showing the whole-file hover blame annotations - const fileAnnotations = await Container.annotations.getAnnotationType(this._editor); - if (fileAnnotations !== undefined && Container.config.hovers.annotations.details) return undefined; - - const wholeLine = Container.config.hovers.currentLine.over === 'line'; - - const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex)); - if (!wholeLine && range.start.character !== position.character) return undefined; - - // Get the full commit message -- since blame only returns the summary - let logCommit = lineState !== undefined ? lineState.logCommit : undefined; - if (logCommit === undefined && !commit.isUncommitted) { - logCommit = await Container.git.getLogCommitForFile(commit.repoPath, commit.uri.fsPath, { ref: commit.sha }); - if (logCommit !== undefined) { - // Preserve the previous commit from the blame commit - logCommit.previousSha = commit.previousSha; - logCommit.previousFileName = commit.previousFileName; - - if (lineState !== undefined) { - lineState.logCommit = logCommit; - } - } - } - - const trackedDocument = await Container.tracker.get(document); - if (trackedDocument === undefined) return undefined; - - const message = Annotations.getHoverMessage(logCommit || commit, Container.config.defaultDateFormat, await Container.git.getRemotes(commit.repoPath), fileAnnotations, position.line); - return new Hover(message, range); - } - - async provideChangesHover(document: TextDocument, position: Position, token: CancellationToken): Promise { - if (this._editor === undefined || this._editor.document !== document || !this._lineTracker.includes(position.line)) return undefined; - - const lineState = this._lineTracker.getState(position.line); - const commit = lineState !== undefined ? lineState.commit : undefined; - if (commit === undefined) return undefined; - - // Avoid double annotations if we are showing the whole-file hover blame annotations - if (Container.config.hovers.annotations.changes) { - const fileAnnotations = await Container.annotations.getAnnotationType(this._editor); - if (fileAnnotations !== undefined) return undefined; - } - - const wholeLine = Container.config.hovers.currentLine.over === 'line'; - - const range = document.validateRange(new Range(position.line, wholeLine ? 0 : RangeEndOfLineIndex, position.line, RangeEndOfLineIndex)); - if (!wholeLine && range.start.character !== position.character) return undefined; - - const trackedDocument = await Container.tracker.get(document); - if (trackedDocument === undefined) return undefined; - - const hover = await Annotations.changesHover(commit, position.line, trackedDocument.uri); - if (hover.hoverMessage === undefined) return undefined; - - return new Hover(hover.hoverMessage, range); - } - - async showAnnotations(editor: TextEditor | undefined) { - this.setBlameAnnotationState(true, editor); - } - - async toggleAnnotations(editor: TextEditor | undefined) { - const state = this.getBlameAnnotationState(); - this.setBlameAnnotationState(!state.enabled, editor); - } - - private async resumeBlameAnnotations(reason: 'debugging' | 'dirty', editor: TextEditor | undefined, options: { force?: boolean } = {}) { - if (!options.force && (this._blameAnnotationState === undefined || !this._blameAnnotationState.suspended)) return; - - let refresh = false; - if (this._blameAnnotationState !== undefined) { - refresh = this._blameAnnotationState.resume(reason); - } - - if (editor === undefined || (!options.force && !refresh)) return; - - await this.refresh(editor); - } - - private async suspendBlameAnnotations(reason: 'debugging' | 'dirty', editor: TextEditor | undefined, options: { force?: boolean } = {}) { - const state = this.getBlameAnnotationState(); - - // If we aren't enabled, suspend doesn't matter - if (this._blameAnnotationState === undefined && !state.enabled) return false; - - if (this._blameAnnotationState === undefined) { - this._blameAnnotationState = new AnnotationState(state.enabled); - } - const refresh = this._blameAnnotationState.suspend(reason); - - if (editor === undefined || (!options.force && !refresh)) return; - - await this.refresh(editor); - return true; - } - - private async setBlameAnnotationState(enabled: boolean, editor: TextEditor | undefined) { - let refresh = true; - if (this._blameAnnotationState === undefined) { - this._blameAnnotationState = new AnnotationState(enabled); - } - else { - refresh = this._blameAnnotationState.reset(enabled); - } - - if (editor === undefined || !refresh) return; - - await this.refresh(editor); - } - - private clearAnnotations(editor: TextEditor | undefined) { - if (editor === undefined || (editor as any)._disposed === true) return; - - editor.setDecorations(annotationDecoration, []); - } - - private getBlameAnnotationState() { - if (this._blameAnnotationState !== undefined) return this._blameAnnotationState; - - const cfg = Container.config; - return { enabled: cfg.currentLine.enabled }; - } - - private _updateBlameDebounced: (((lines: number[], editor: TextEditor, trackedDocument: TrackedDocument) => void) & IDeferrable) | undefined; - - private async refresh(editor: TextEditor | undefined, options: { full?: boolean, trackedDocument?: TrackedDocument } = {}) { - if (editor === undefined && this._editor === undefined) return; - - if (editor === undefined || this._lineTracker.lines === undefined) return this.clear(this._editor); - - if (this._editor !== editor) { - // If we are changing editor, consider this a full refresh - options.full = true; - - // Clear any annotations on the previously active editor - this.clearAnnotations(this._editor); - - this._editor = editor; - } - - const state = this.getBlameAnnotationState(); - if (state.enabled || Container.config.statusBar.enabled || (Container.config.hovers.enabled && Container.config.hovers.currentLine.enabled)) { - if (options.trackedDocument === undefined) { - options.trackedDocument = await Container.tracker.getOrAdd(editor.document); - } - - if (options.trackedDocument.isBlameable) { - if (Container.config.hovers.enabled && Container.config.hovers.currentLine.enabled && - (options.full || this._hoverProviderDisposable === undefined)) { - this.registerHoverProviders(editor, Container.config.hovers.currentLine); - } - - if (this._updateBlameDebounced === undefined) { - this._updateBlameDebounced = Functions.debounce(this.updateBlame, 50, { track: true }); - } - this._updateBlameDebounced(this._lineTracker.lines, editor, options.trackedDocument); - - return; - } - } - - await this.clear(editor); - } - - private registerHoverProviders(editor: TextEditor | undefined, providers: { details: boolean, changes: boolean }) { - this.unregisterHoverProviders(); - - if (editor === undefined) return; - if (!providers.details && !providers.changes) return; - - const subscriptions: Disposable[] = []; - if (providers.changes) { - subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideChangesHover.bind(this) } as HoverProvider)); - } - if (providers.details) { - subscriptions.push(languages.registerHoverProvider({ pattern: editor.document.uri.fsPath }, { provideHover: this.provideDetailsHover.bind(this) } as HoverProvider)); - } - - this._hoverProviderDisposable = Disposable.from(...subscriptions); - } - - private unregisterHoverProviders() { - if (this._hoverProviderDisposable !== undefined) { - this._hoverProviderDisposable.dispose(); - this._hoverProviderDisposable = undefined; - } - } - - private async updateBlame(lines: number[], editor: TextEditor, trackedDocument: TrackedDocument) { - this._lineTracker.reset(); - - // Make sure we are still on the same line and not pending - if (!this._lineTracker.includesAll(lines) || (this._updateBlameDebounced && this._updateBlameDebounced.pending!())) return; - - let blameLines; - if (lines.length === 1) { - const blameLine = editor.document.isDirty - ? await Container.git.getBlameForLineContents(trackedDocument.uri, lines[0], editor.document.getText()) - : await Container.git.getBlameForLine(trackedDocument.uri, lines[0]); - if (blameLine === undefined) return this.clear(editor); - - blameLines = [blameLine]; - } - else { - const blame = editor.document.isDirty - ? await Container.git.getBlameForFileContents(trackedDocument.uri, editor.document.getText()) - : await Container.git.getBlameForFile(trackedDocument.uri); - if (blame === undefined) return this.clear(editor); - - blameLines = lines.map(l => { - const commitLine = blame.lines[l]; - return { - line: commitLine, - commit: blame.commits.get(commitLine.sha)! - }; - }); - } - - // Make sure we are still on the same line, blameable, and not pending, after the await - if (this._lineTracker.includesAll(lines) && trackedDocument.isBlameable && !(this._updateBlameDebounced && this._updateBlameDebounced.pending!())) { - if (!this.getBlameAnnotationState().enabled) { - if (!Container.config.statusBar.enabled) return this.clear(editor); - - this.clearAnnotations(editor); - } - } - - const activeLine = blameLines[0]; - this._lineTracker.setState(activeLine.line.line, new GitLineState(activeLine.commit)); - - // I have no idea why I need this protection -- but it happens - if (editor.document === undefined) return; - - if (editor.document.isDirty) { - const trackedDocument = await Container.tracker.get(editor.document); - if (trackedDocument !== undefined) { - trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); - } - } - - this.updateStatusBar(activeLine.commit, editor); - this.updateTrailingAnnotations(blameLines, editor); - } - - private updateStatusBar(commit: GitCommit, editor: TextEditor) { - const cfg = Container.config.statusBar; - if (!cfg.enabled || this._statusBarItem === undefined || !isTextEditor(editor)) return; - - this._statusBarItem.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, { - truncateMessageAtNewLine: true, - dateFormat: cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat - } as ICommitFormatOptions)}`; - - switch (cfg.command) { - case StatusBarCommand.ToggleFileBlame: - this._statusBarItem.tooltip = 'Toggle Blame Annotations'; - break; - case StatusBarCommand.DiffWithPrevious: - this._statusBarItem.command = Commands.DiffLineWithPrevious; - this._statusBarItem.tooltip = 'Compare Line Revision with Previous'; - break; - case StatusBarCommand.DiffWithWorking: - this._statusBarItem.command = Commands.DiffLineWithWorking; - this._statusBarItem.tooltip = 'Compare Line Revision with Working'; - break; - case StatusBarCommand.ToggleCodeLens: - this._statusBarItem.tooltip = 'Toggle Git CodeLens'; - break; - case StatusBarCommand.ShowQuickCommitDetails: - this._statusBarItem.tooltip = 'Show Commit Details'; - break; - case StatusBarCommand.ShowQuickCommitFileDetails: - this._statusBarItem.tooltip = 'Show Line Commit Details'; - break; - case StatusBarCommand.ShowQuickFileHistory: - this._statusBarItem.tooltip = 'Show File History'; - break; - case StatusBarCommand.ShowQuickCurrentBranchHistory: - this._statusBarItem.tooltip = 'Show Branch History'; - break; - } - - this._statusBarItem.show(); - } - - private async updateTrailingAnnotations(lines: GitBlameLine[], editor: TextEditor) { - const cfg = Container.config.currentLine; - if (!this.getBlameAnnotationState().enabled || !isTextEditor(editor)) return this.clearAnnotations(editor); - - const decorations = []; - for (const l of lines) { - const line = l.line.line; - - const decoration = Annotations.trailing(l.commit, cfg.format, cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat); - decoration.range = editor.document.validateRange(new Range(line, RangeEndOfLineIndex, line, RangeEndOfLineIndex)); - decorations.push(decoration); - } - - editor.setDecorations(annotationDecoration, decorations); - } -} \ No newline at end of file diff --git a/src/statusBarController.ts b/src/statusBarController.ts new file mode 100644 index 0000000..5098cd9 --- /dev/null +++ b/src/statusBarController.ts @@ -0,0 +1,136 @@ +'use strict'; +import { ConfigurationChangeEvent, Disposable, StatusBarAlignment, StatusBarItem, TextEditor, window } from 'vscode'; +import { Commands } from './commands'; +import { configuration, IConfig, StatusBarCommand } from './configuration'; +import { isTextEditor } from './constants'; +import { Container } from './container'; +import { LinesChangeEvent } from './trackers/gitLineTracker'; +import { CommitFormatter, GitCommit, ICommitFormatOptions } from './gitService'; + +export class StatusBarController extends Disposable { + + private _disposable: Disposable; + private _statusBarItem: StatusBarItem | undefined; + + constructor() { + super(() => this.dispose()); + + this._disposable = Disposable.from( + configuration.onDidChange(this.onConfigurationChanged, this) + ); + this.onConfigurationChanged(configuration.initializingChangeEvent); + } + + dispose() { + this.clear(); + + this._statusBarItem && this._statusBarItem.dispose(); + + Container.lineTracker.stop(this); + this._disposable && this._disposable.dispose(); + } + + private onConfigurationChanged(e: ConfigurationChangeEvent) { + const initializing = configuration.initializing(e); + + if (!initializing && !configuration.changed(e, configuration.name('statusBar').value)) return; + + const cfg = configuration.get(); + if (cfg.statusBar.enabled) { + const alignment = cfg.statusBar.alignment !== 'left' ? StatusBarAlignment.Right : StatusBarAlignment.Left; + + if (configuration.changed(e, configuration.name('statusBar')('alignment').value)) { + if (this._statusBarItem !== undefined && this._statusBarItem.alignment !== alignment) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } + + this._statusBarItem = this._statusBarItem || window.createStatusBarItem(alignment, alignment === StatusBarAlignment.Right ? 1000 : 0); + this._statusBarItem.command = cfg.statusBar.command; + + if (initializing || configuration.changed(e, configuration.name('statusBar')('enabled').value)) { + Container.lineTracker.start( + this, + Disposable.from(Container.lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this)) + ); + } + } + else { + if (configuration.changed(e, configuration.name('statusBar')('enabled').value)) { + Container.lineTracker.stop(this); + + if (this._statusBarItem !== undefined) { + this._statusBarItem.dispose(); + this._statusBarItem = undefined; + } + } + } + } + + private onActiveLinesChanged(e: LinesChangeEvent) { + // If we need to reduceFlicker, don't clear if only the selected lines changed + let clear = !(Container.config.statusBar.reduceFlicker && e.reason === 'selection' && (e.pending || e.lines !== undefined)); + if (!e.pending && e.lines !== undefined) { + const state = Container.lineTracker.getState(e.lines[0]); + if (state !== undefined && state.commit !== undefined) { + this.updateStatusBar(state.commit, e.editor!); + + return; + } + + clear = true; + } + + if (clear) { + this.clear(); + } + } + + async clear() { + if (this._statusBarItem !== undefined) { + this._statusBarItem.hide(); + } + } + + private updateStatusBar(commit: GitCommit, editor: TextEditor) { + const cfg = Container.config.statusBar; + if (!cfg.enabled || this._statusBarItem === undefined || !isTextEditor(editor)) return; + + this._statusBarItem.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, { + truncateMessageAtNewLine: true, + dateFormat: cfg.dateFormat === null ? Container.config.defaultDateFormat : cfg.dateFormat + } as ICommitFormatOptions)}`; + + switch (cfg.command) { + case StatusBarCommand.ToggleFileBlame: + this._statusBarItem.tooltip = 'Toggle Blame Annotations'; + break; + case StatusBarCommand.DiffWithPrevious: + this._statusBarItem.command = Commands.DiffLineWithPrevious; + this._statusBarItem.tooltip = 'Compare Line Revision with Previous'; + break; + case StatusBarCommand.DiffWithWorking: + this._statusBarItem.command = Commands.DiffLineWithWorking; + this._statusBarItem.tooltip = 'Compare Line Revision with Working'; + break; + case StatusBarCommand.ToggleCodeLens: + this._statusBarItem.tooltip = 'Toggle Git CodeLens'; + break; + case StatusBarCommand.ShowQuickCommitDetails: + this._statusBarItem.tooltip = 'Show Commit Details'; + break; + case StatusBarCommand.ShowQuickCommitFileDetails: + this._statusBarItem.tooltip = 'Show Line Commit Details'; + break; + case StatusBarCommand.ShowQuickFileHistory: + this._statusBarItem.tooltip = 'Show File History'; + break; + case StatusBarCommand.ShowQuickCurrentBranchHistory: + this._statusBarItem.tooltip = 'Show Branch History'; + break; + } + + this._statusBarItem.show(); + } +} \ No newline at end of file diff --git a/src/trackers/gitLineTracker.ts b/src/trackers/gitLineTracker.ts index 8a3303b..d460123 100644 --- a/src/trackers/gitLineTracker.ts +++ b/src/trackers/gitLineTracker.ts @@ -1,6 +1,9 @@ 'use strict'; +import { Disposable, TextEditor } from 'vscode'; import { GitBlameCommit, GitLogCommit } from '../gitService'; -import { LineTracker } from './lineTracker'; +import { LinesChangeEvent, LineTracker } from './lineTracker'; +import { Container } from '../container'; +import { DocumentBlameStateChangeEvent, DocumentDirtyIdleTriggerEvent, DocumentDirtyStateChangeEvent, GitDocumentState } from './gitDocumentTracker'; export * from './lineTracker'; @@ -15,21 +18,81 @@ export class GitLineState { export class GitLineTracker extends LineTracker { private _count = 0; + private _subscriptions: Map = new Map(); - start() { - if (this._disposable !== undefined) { - this._count = 0; - return; + protected async fireLinesChanged(e: LinesChangeEvent) { + this.reset(); + + let updated = false; + if (!this._suspended && !e.pending && e.lines !== undefined && e.editor !== undefined) { + updated = await this.updateState(e.lines, e.editor); } + super.fireLinesChanged(updated ? e : { ...e, lines: undefined }); + } + + private onBlameStateChanged(e: DocumentBlameStateChangeEvent) { + this.trigger('editor'); + } + + private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent) { + const maxLines = Container.config.advanced.blame.sizeThresholdAfterEdit; + if (maxLines > 0 && e.document.lineCount > maxLines) return; + + this.resume(); + } + + private async onDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { + if (e.dirty) { + this.suspend(); + } + else { + this.resume({ force: true }); + } + } + + private _suspended = false; + + private async resume(options: { force?: boolean } = {}) { + if (!options.force && !this._suspended) return; + + this._suspended = false; + this.trigger('editor'); + } + + private async suspend(options: { force?: boolean } = {}) { + if (!options.force && this._suspended) return; + + this._suspended = true; + this.trigger('editor'); + } + + start(subscriber: any, subscription: Disposable): void { + if (this._subscriptions.has(subscriber)) return; + + this._subscriptions.set(subscriber, subscription); + this._count++; if (this._count === 1) { super.start(); + + this._disposable = Disposable.from( + this._disposable!, + Container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this), + Container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this), + Container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this) + ); } } - stop() { - if (this._disposable !== undefined) { + stop(subscriber: any) { + const subscription = this._subscriptions.get(subscriber); + if (subscription === undefined) return; + + this._subscriptions.delete(subscriber); + subscription.dispose(); + + if (this._disposable === undefined) { this._count = 0; return; } @@ -39,4 +102,37 @@ export class GitLineTracker extends LineTracker { super.stop(); } } + + private async updateState(lines: number[], editor: TextEditor): Promise { + const trackedDocument = await Container.tracker.getOrAdd(editor.document); + if (!trackedDocument.isBlameable || !this.includesAll(lines)) return false; + + if (lines.length === 1) { + const blameLine = editor.document.isDirty + ? await Container.git.getBlameForLineContents(trackedDocument.uri, lines[0], editor.document.getText()) + : await Container.git.getBlameForLine(trackedDocument.uri, lines[0]); + if (blameLine === undefined) return false; + + this.setState(blameLine.line.line, new GitLineState(blameLine.commit)); + } + else { + const blame = editor.document.isDirty + ? await Container.git.getBlameForFileContents(trackedDocument.uri, editor.document.getText()) + : await Container.git.getBlameForFile(trackedDocument.uri); + if (blame === undefined) return false; + + for (const line of lines) { + const commitLine = blame.lines[line]; + this.setState(line, new GitLineState(blame.commits.get(commitLine.sha)!)); + } + } + + if (!trackedDocument.isBlameable || !this.includesAll(lines)) return false; + + if (editor.document.isDirty) { + trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); + } + + return true; + } } diff --git a/src/trackers/lineTracker.ts b/src/trackers/lineTracker.ts index a679ba1..9bba40b 100644 --- a/src/trackers/lineTracker.ts +++ b/src/trackers/lineTracker.ts @@ -8,7 +8,7 @@ export interface LinesChangeEvent { readonly editor: TextEditor | undefined; readonly lines: number[] | undefined; - readonly reason: 'editor' | 'lines'; + readonly reason: 'editor' | 'selection'; readonly pending?: boolean; } @@ -16,7 +16,6 @@ export class LineTracker extends Disposable { private _onDidChangeActiveLines = new EventEmitter(); get onDidChangeActiveLines(): Event { - this._onDidChangeActiveLines.event.length; return this._onDidChangeActiveLines.event; } @@ -41,14 +40,14 @@ export class LineTracker extends Disposable { this._editor = editor; this._lines = editor !== undefined ? editor.selections.map(s => s.active.line) : undefined; - this.fireLinesChanged({ editor: editor, lines: this._lines, reason: 'editor' }); + this.trigger('editor'); } private onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) { // If this isn't for our cached editor and its not a real editor -- kick out if (this._editor !== e.textEditor && !isTextEditor(e.textEditor)) return; - const reason = this._editor === e.textEditor ? 'lines' : 'editor'; + const reason = this._editor === e.textEditor ? 'selection' : 'editor'; const lines = e.selections.map(s => s.active.line); if (this._editor === e.textEditor && this.includesAll(lines)) return; @@ -56,8 +55,7 @@ export class LineTracker extends Disposable { this.reset(); this._editor = e.textEditor; this._lines = lines; - - this.fireLinesChanged({ editor: this._editor, lines: this._lines, reason: reason }); + this.trigger(reason); } getState(line: number): T | undefined { @@ -81,11 +79,15 @@ export class LineTracker extends Disposable { return LineTracker.includesAll(lines, this._lines); } + refresh() { + this.trigger('editor'); + } + reset() { this._state.clear(); } - start() { + start(subscriber?: any, subscription?: Disposable): void { if (this._disposable !== undefined) return; this._disposable = Disposable.from( @@ -96,7 +98,7 @@ export class LineTracker extends Disposable { setImmediate(() => this.onActiveTextEditorChanged(window.activeTextEditor)); } - stop() { + stop(subscriber?: any) { if (this._disposable === undefined) return; if (this._linesChangedDebounced !== undefined) { @@ -107,9 +109,17 @@ export class LineTracker extends Disposable { this._disposable = undefined; } + protected async fireLinesChanged(e: LinesChangeEvent) { + this._onDidChangeActiveLines.fire(e); + } + + protected trigger(reason: 'editor' | 'selection') { + this.onLinesChanged({ editor: this._editor, lines: this._lines, reason: reason }); + } + private _linesChangedDebounced: (((e: LinesChangeEvent) => void) & IDeferrable) | undefined; - private fireLinesChanged(e: LinesChangeEvent) { + private onLinesChanged(e: LinesChangeEvent) { if (e.lines === undefined) { setImmediate(() => { if (window.activeTextEditor !== e.editor) return; @@ -118,7 +128,7 @@ export class LineTracker extends Disposable { this._linesChangedDebounced.cancel(); } - this._onDidChangeActiveLines.fire(e); + this.fireLinesChanged(e); }); return; @@ -130,13 +140,13 @@ export class LineTracker extends Disposable { // Make sure we are still on the same lines if (!LineTracker.includesAll(e.lines , (e.editor && e.editor.selections.map(s => s.active.line)))) return; - this._onDidChangeActiveLines.fire(e); + this.fireLinesChanged(e); }, 250, { track: true }); } // If we have no pending moves, then fire an immediate pending event, and defer the real event if (!this._linesChangedDebounced.pending!()) { - this._onDidChangeActiveLines.fire({ ...e, pending: true }); + this.fireLinesChanged({ ...e, pending: true }); } this._linesChangedDebounced(e);