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/fileAnnotationController.ts similarity index 96% rename from src/annotations/annotationController.ts rename to src/annotations/fileAnnotationController.ts index 3911298..23a0e26 100644 --- a/src/annotations/annotationController.ts +++ b/src/annotations/fileAnnotationController.ts @@ -34,7 +34,7 @@ export const Decorations = { recentChangesHighlight: undefined as TextEditorDecorationType | undefined }; -export class AnnotationController extends Disposable { +export class FileAnnotationController extends Disposable { private _onDidToggleAnnotations = new EventEmitter(); get onDidToggleAnnotations(): Event { @@ -173,7 +173,7 @@ export class AnnotationController extends Disposable { provider.reset({ decoration: Decorations.blameAnnotation, highlightDecoration: Decorations.blameHighlight }); } else { - this.showAnnotations(provider.editor, FileAnnotationType.Heatmap); + this.show(provider.editor, FileAnnotationType.Heatmap); } } } @@ -186,7 +186,7 @@ export class AnnotationController extends Disposable { // Logger.log('AnnotationController.onActiveTextEditorChanged', editor && editor.document.uri.fsPath); if (this.isInWindowToggle()) { - await this.showAnnotations(editor, this._annotationType!); + await this.show(editor, this._annotationType!); return; } @@ -296,7 +296,7 @@ export class AnnotationController extends Disposable { return this._annotationProviders.get(AnnotationProviderBase.getCorrelationKey(editor)); } - async showAnnotations(editor: TextEditor | undefined, type: FileAnnotationType, shaOrLine?: string | number): Promise { + 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; @@ -312,7 +312,7 @@ export class AnnotationController extends Disposable { for (const e of window.visibleTextEditors) { if (e === editor) continue; - this.showAnnotations(e, type); + this.show(e, type); } } } @@ -345,14 +345,14 @@ export class AnnotationController extends Disposable { return provider !== undefined; } - async toggleAnnotations(editor: TextEditor | undefined, type: FileAnnotationType, shaOrLine?: string | number): Promise { + 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.showAnnotations(editor!, type, shaOrLine); + if (provider === undefined) return this.show(editor!, type, shaOrLine); const reopen = provider.annotationType !== type; @@ -365,7 +365,7 @@ export class AnnotationController extends Disposable { if (!reopen) return false; - return this.showAnnotations(editor, type, shaOrLine); + return this.show(editor, type, shaOrLine); } private async attachKeyboardHook() { 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);