|
'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<GitLineState>;
|
|
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<GitLineState>();
|
|
|
|
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<IConfig>();
|
|
|
|
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<GitDocumentState>) {
|
|
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<GitDocumentState>) {
|
|
const maxLines = configuration.get<number>(configuration.name('advanced')('blame')('sizeThresholdAfterEdit').value);
|
|
if (maxLines > 0 && e.document.lineCount > maxLines) return;
|
|
|
|
this.resumeBlameAnnotations('dirty', window.activeTextEditor);
|
|
}
|
|
|
|
private async onDirtyStateChanged(e: DocumentDirtyStateChangeEvent<GitDocumentState>) {
|
|
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<Hover | undefined> {
|
|
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<Hover | undefined> {
|
|
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<GitDocumentState>) => void) & IDeferrable) | undefined;
|
|
|
|
private async refresh(editor: TextEditor | undefined, options: { full?: boolean, trackedDocument?: TrackedDocument<GitDocumentState> } = {}) {
|
|
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<GitDocumentState>) {
|
|
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);
|
|
}
|
|
}
|