'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/documentTracker'; import { CommitFormatter, GitBlameLine, GitCommit, ICommitFormatOptions } from './gitService'; import { GitLineState, LinesChangeEvent, LineTracker } from './trackers/lineTracker'; 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: LineTracker; 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 = new 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._lineTracker.reset(); this.unregisterHoverProviders(); 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) return; if ((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 || cfg.statusBar.enabled || (cfg.hovers.enabled && cfg.hovers.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) { if (options.trackedDocument === undefined) { options.trackedDocument = await Container.tracker.getOrAdd(editor.document); } if (options.trackedDocument.isBlameable) { if (state.enabled && 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) return this.clear(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 (!cfg.enabled || !isTextEditor(editor)) return; 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); } }