Adds all-new, beautiful, highly customizable and themeable, file blame annotations Adds all-new configurability and themeability to the current line blame annotations Adds all-new configurability to the status bar blame information Adds all-new configurability over which commands are added to which menus via the `gitlens.advanced.menus` setting Adds better configurability over where Git code lens will be shown -- both by default and per language Adds an all-new `changes` (diff) hover annotation to the current line - provides instant access to the line's previous version Adds `Toggle Line Blame Annotations` command (`gitlens.toggleLineBlame`) - toggles the current line blame annotations on and off Adds `Show Line Blame Annotations` command (`gitlens.showLineBlame`) - shows the current line blame annotations Adds `Toggle File Blame Annotations` command (`gitlens.toggleFileBlame`) - toggles the file blame annotations on and off Adds `Show File Blame Annotations` command (`gitlens.showFileBlame`) - shows the file blame annotations Adds `Open File in Remote` command (`gitlens.openFileInRemote`) to the `editor/title` context menu Adds `Open Repo in Remote` command (`gitlens.openRepoInRemote`) to the `editor/title` context menu Changes the position of the `Open File in Remote` command (`gitlens.openFileInRemote`) in the context menus - now in the `navigation` group Changes the `Toggle Git Code Lens` command (`gitlens.toggleCodeLens`) to always toggle the Git code lens on and off Removes the on-demand `trailing` file blame annotations -- didn't work out and just ended up with a ton of visual noise Removes `Toggle Blame Annotations` command (`gitlens.toggleBlame`) - replaced by the `Toggle File Blame Annotations` command (`gitlens.toggleFileBlame`) Removes `Show Blame Annotations` command (`gitlens.showBlame`) - replaced by the `Show File Blame Annotations` command (`gitlens.showFileBlame`)main
@ -1,6 +1,6 @@ | |||
<?xml version="1.0" encoding="utf-8"?> | |||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="18px" height="18px" viewBox="0 0 18 18" xml:space="preserve"> | |||
<g> | |||
<rect fill="#FFFFFF" fill-opacity="0.75" x="1" y="0" width="4" height="18"/> | |||
<rect fill="#00bcf2" fill-opacity="0.6" x="7" y="0" width="3" height="18"/> | |||
</g> | |||
</svg> |
@ -1,6 +1,6 @@ | |||
<?xml version="1.0" encoding="utf-8"?> | |||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="18px" height="18px" viewBox="0 0 18 18" xml:space="preserve"> | |||
<g> | |||
<rect fill="#000000" fill-opacity="0.75" x="1" y="0" width="4" height="18"/> | |||
<rect fill="#00bcf2" fill-opacity="0.6" x="7" y="0" width="3" height="18"/> | |||
</g> | |||
</svg> |
@ -0,0 +1,282 @@ | |||
'use strict'; | |||
import { Functions, Objects } from '../system'; | |||
import { DecorationRenderOptions, Disposable, Event, EventEmitter, ExtensionContext, OverviewRulerLane, TextDocument, TextDocumentChangeEvent, TextEditor, TextEditorDecorationType, TextEditorViewColumnChangeEvent, window, workspace } from 'vscode'; | |||
import { AnnotationProviderBase } from './annotationProvider'; | |||
import { TextDocumentComparer, TextEditorComparer } from '../comparers'; | |||
import { BlameLineHighlightLocations, ExtensionKey, FileAnnotationType, IConfig, themeDefaults } from '../configuration'; | |||
import { BlameabilityChangeEvent, GitContextTracker, GitService, GitUri } from '../gitService'; | |||
import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider'; | |||
import { HoverBlameAnnotationProvider } from './hoverBlameAnnotationProvider'; | |||
import { Logger } from '../logger'; | |||
import { WhitespaceController } from './whitespaceController'; | |||
export const Decorations = { | |||
annotation: window.createTextEditorDecorationType({ | |||
isWholeLine: true | |||
} as DecorationRenderOptions), | |||
highlight: undefined as TextEditorDecorationType | undefined | |||
}; | |||
export class AnnotationController extends Disposable { | |||
private _onDidToggleAnnotations = new EventEmitter<void>(); | |||
get onDidToggleAnnotations(): Event<void> { | |||
return this._onDidToggleAnnotations.event; | |||
} | |||
private _annotationsDisposable: Disposable | undefined; | |||
private _annotationProviders: Map<number, AnnotationProviderBase> = new Map(); | |||
private _config: IConfig; | |||
private _disposable: Disposable; | |||
private _whitespaceController: WhitespaceController | undefined; | |||
constructor(private context: ExtensionContext, private git: GitService, private gitContextTracker: GitContextTracker) { | |||
super(() => this.dispose()); | |||
this._onConfigurationChanged(); | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); | |||
this._disposable = Disposable.from(...subscriptions); | |||
} | |||
dispose() { | |||
this._annotationProviders.forEach(async (p, i) => await this.clear(i)); | |||
Decorations.annotation && Decorations.annotation.dispose(); | |||
Decorations.highlight && Decorations.highlight.dispose(); | |||
this._annotationsDisposable && this._annotationsDisposable.dispose(); | |||
this._whitespaceController && this._whitespaceController.dispose(); | |||
this._disposable && this._disposable.dispose(); | |||
} | |||
private _onConfigurationChanged() { | |||
let toggleWhitespace = workspace.getConfiguration(`${ExtensionKey}.advanced.toggleWhitespace`).get<boolean>('enabled'); | |||
if (!toggleWhitespace) { | |||
// Until https://github.com/Microsoft/vscode/issues/11485 is fixed we need to toggle whitespace for non-monospace fonts and ligatures | |||
// TODO: detect monospace font | |||
toggleWhitespace = workspace.getConfiguration('editor').get<boolean>('fontLigatures'); | |||
} | |||
if (toggleWhitespace && !this._whitespaceController) { | |||
this._whitespaceController = new WhitespaceController(); | |||
} | |||
else if (!toggleWhitespace && this._whitespaceController) { | |||
this._whitespaceController.dispose(); | |||
this._whitespaceController = undefined; | |||
} | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
const cfgHighlight = cfg.blame.file.lineHighlight; | |||
const cfgTheme = cfg.theme.lineHighlight; | |||
let changed = false; | |||
if (!Objects.areEquivalent(cfgHighlight, this._config && this._config.blame.file.lineHighlight) || | |||
!Objects.areEquivalent(cfgTheme, this._config && this._config.theme.lineHighlight)) { | |||
changed = true; | |||
Decorations.highlight && Decorations.highlight.dispose(); | |||
if (cfgHighlight.enabled) { | |||
Decorations.highlight = window.createTextEditorDecorationType({ | |||
gutterIconSize: 'contain', | |||
isWholeLine: true, | |||
overviewRulerLane: OverviewRulerLane.Right, | |||
dark: { | |||
backgroundColor: cfgHighlight.locations.includes(BlameLineHighlightLocations.Line) | |||
? cfgTheme.dark.backgroundColor || themeDefaults.lineHighlight.dark.backgroundColor | |||
: undefined, | |||
gutterIconPath: cfgHighlight.locations.includes(BlameLineHighlightLocations.Gutter) | |||
? this.context.asAbsolutePath('images/blame-dark.svg') | |||
: undefined, | |||
overviewRulerColor: cfgHighlight.locations.includes(BlameLineHighlightLocations.OverviewRuler) | |||
? cfgTheme.dark.overviewRulerColor || themeDefaults.lineHighlight.dark.overviewRulerColor | |||
: undefined | |||
}, | |||
light: { | |||
backgroundColor: cfgHighlight.locations.includes(BlameLineHighlightLocations.Line) | |||
? cfgTheme.light.backgroundColor || themeDefaults.lineHighlight.light.backgroundColor | |||
: undefined, | |||
gutterIconPath: cfgHighlight.locations.includes(BlameLineHighlightLocations.Gutter) | |||
? this.context.asAbsolutePath('images/blame-light.svg') | |||
: undefined, | |||
overviewRulerColor: cfgHighlight.locations.includes(BlameLineHighlightLocations.OverviewRuler) | |||
? cfgTheme.light.overviewRulerColor || themeDefaults.lineHighlight.light.overviewRulerColor | |||
: undefined | |||
} | |||
}); | |||
} | |||
else { | |||
Decorations.highlight = undefined; | |||
} | |||
} | |||
if (!Objects.areEquivalent(cfg.blame.file, this._config && this._config.blame.file) || | |||
!Objects.areEquivalent(cfg.annotations, this._config && this._config.annotations) || | |||
!Objects.areEquivalent(cfg.theme.annotations, this._config && this._config.theme.annotations)) { | |||
changed = true; | |||
} | |||
this._config = cfg; | |||
if (changed) { | |||
// Since the configuration has changed -- reset any visible annotations | |||
for (const provider of this._annotationProviders.values()) { | |||
if (provider === undefined) continue; | |||
provider.reset(); | |||
} | |||
} | |||
} | |||
async clear(column: number) { | |||
const provider = this._annotationProviders.get(column); | |||
if (!provider) return; | |||
this._annotationProviders.delete(column); | |||
await provider.dispose(); | |||
if (this._annotationProviders.size === 0) { | |||
Logger.log(`Remove listener registrations for annotations`); | |||
this._annotationsDisposable && this._annotationsDisposable.dispose(); | |||
this._annotationsDisposable = undefined; | |||
} | |||
this._onDidToggleAnnotations.fire(); | |||
} | |||
getAnnotationType(editor: TextEditor): FileAnnotationType | undefined { | |||
const provider = this.getProvider(editor); | |||
return provider === undefined ? undefined : provider.annotationType; | |||
} | |||
getProvider(editor: TextEditor): AnnotationProviderBase | undefined { | |||
if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return undefined; | |||
return this._annotationProviders.get(editor.viewColumn || -1); | |||
} | |||
async showAnnotations(editor: TextEditor, type: FileAnnotationType, shaOrLine?: string | number): Promise<boolean> { | |||
if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; | |||
const currentProvider = this._annotationProviders.get(editor.viewColumn || -1); | |||
if (currentProvider && TextEditorComparer.equals(currentProvider.editor, editor)) { | |||
await currentProvider.selection(shaOrLine); | |||
return true; | |||
} | |||
const gitUri = await GitUri.fromUri(editor.document.uri, this.git); | |||
let provider: AnnotationProviderBase | undefined = undefined; | |||
switch (type) { | |||
case FileAnnotationType.Gutter: | |||
provider = new GutterBlameAnnotationProvider(this.context, editor, Decorations.annotation, Decorations.highlight, this._whitespaceController, this.git, gitUri); | |||
break; | |||
case FileAnnotationType.Hover: | |||
provider = new HoverBlameAnnotationProvider(this.context, editor, Decorations.annotation, Decorations.highlight, this._whitespaceController, this.git, gitUri); | |||
break; | |||
} | |||
if (provider === undefined || !(await provider.validate())) return false; | |||
if (currentProvider) { | |||
await this.clear(currentProvider.editor.viewColumn || -1); | |||
} | |||
if (!this._annotationsDisposable && this._annotationProviders.size === 0) { | |||
Logger.log(`Add listener registrations for annotations`); | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(window.onDidChangeVisibleTextEditors(Functions.debounce(this._onVisibleTextEditorsChanged, 100), this)); | |||
subscriptions.push(window.onDidChangeTextEditorViewColumn(this._onTextEditorViewColumnChanged, this)); | |||
subscriptions.push(workspace.onDidChangeTextDocument(this._onTextDocumentChanged, this)); | |||
subscriptions.push(workspace.onDidCloseTextDocument(this._onTextDocumentClosed, this)); | |||
subscriptions.push(this.gitContextTracker.onDidBlameabilityChange(this._onBlameabilityChanged, this)); | |||
this._annotationsDisposable = Disposable.from(...subscriptions); | |||
} | |||
this._annotationProviders.set(editor.viewColumn || -1, provider); | |||
if (await provider.provideAnnotation(shaOrLine)) { | |||
this._onDidToggleAnnotations.fire(); | |||
return true; | |||
} | |||
return false; | |||
} | |||
async toggleAnnotations(editor: TextEditor, type: FileAnnotationType, shaOrLine?: string | number): Promise<boolean> { | |||
if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; | |||
const provider = this._annotationProviders.get(editor.viewColumn || -1); | |||
if (provider === undefined) return this.showAnnotations(editor, type, shaOrLine); | |||
await this.clear(provider.editor.viewColumn || -1); | |||
return false; | |||
} | |||
private _onBlameabilityChanged(e: BlameabilityChangeEvent) { | |||
if (e.blameable || !e.editor) return; | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextDocumentComparer.equals(p.document, e.editor.document)) continue; | |||
Logger.log('BlameabilityChanged:', `Clear annotations for column ${key}`); | |||
this.clear(key); | |||
} | |||
} | |||
private _onTextDocumentChanged(e: TextDocumentChangeEvent) { | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextDocumentComparer.equals(p.document, e.document)) continue; | |||
// We have to defer because isDirty is not reliable inside this event | |||
setTimeout(() => { | |||
// If the document is dirty all is fine, just kick out since the GitContextTracker will handle it | |||
if (e.document.isDirty) return; | |||
// If the document isn't dirty, it is very likely this event was triggered by an outside edit of this document | |||
// Which means the document has been reloaded and the annotations have been removed, so we need to update (clear) our state tracking | |||
Logger.log('TextDocumentChanged:', `Clear annotations for column ${key}`); | |||
this.clear(key); | |||
}, 1); | |||
} | |||
} | |||
private _onTextDocumentClosed(e: TextDocument) { | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextDocumentComparer.equals(p.document, e)) continue; | |||
Logger.log('TextDocumentClosed:', `Clear annotations for column ${key}`); | |||
this.clear(key); | |||
} | |||
} | |||
private async _onTextEditorViewColumnChanged(e: TextEditorViewColumnChangeEvent) { | |||
const viewColumn = e.viewColumn || -1; | |||
Logger.log('TextEditorViewColumnChanged:', `Clear annotations for column ${viewColumn}`); | |||
await this.clear(viewColumn); | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextEditorComparer.equals(p.editor, e.textEditor)) continue; | |||
Logger.log('TextEditorViewColumnChanged:', `Clear annotations for column ${key}`); | |||
await this.clear(key); | |||
} | |||
} | |||
private async _onVisibleTextEditorsChanged(e: TextEditor[]) { | |||
if (e.every(_ => _.document.uri.scheme === 'inmemory')) return; | |||
for (const [key, p] of this._annotationProviders) { | |||
if (e.some(_ => TextEditorComparer.equals(p.editor, _))) continue; | |||
Logger.log('VisibleTextEditorsChanged:', `Clear annotations for column ${key}`); | |||
this.clear(key); | |||
} | |||
} | |||
} |
@ -0,0 +1,74 @@ | |||
'use strict'; | |||
import { Functions } from '../system'; | |||
import { Disposable, ExtensionContext, TextDocument, TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent, window, workspace } from 'vscode'; | |||
import { TextDocumentComparer } from '../comparers'; | |||
import { ExtensionKey, FileAnnotationType, IConfig } from '../configuration'; | |||
import { WhitespaceController } from './whitespaceController'; | |||
export abstract class AnnotationProviderBase extends Disposable { | |||
public annotationType: FileAnnotationType; | |||
public document: TextDocument; | |||
protected _config: IConfig; | |||
protected _disposable: Disposable; | |||
constructor(context: ExtensionContext, public editor: TextEditor, protected decoration: TextEditorDecorationType, protected highlightDecoration: TextEditorDecorationType | undefined, protected whitespaceController: WhitespaceController | undefined) { | |||
super(() => this.dispose()); | |||
this.document = this.editor.document; | |||
this._config = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(window.onDidChangeTextEditorSelection(this._onTextEditorSelectionChanged, this)); | |||
this._disposable = Disposable.from(...subscriptions); | |||
} | |||
async dispose() { | |||
await this.clear(); | |||
this._disposable && this._disposable.dispose(); | |||
} | |||
private async _onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) { | |||
if (!TextDocumentComparer.equals(this.document, e.textEditor && e.textEditor.document)) return; | |||
return this.selection(e.selections[0].active.line); | |||
} | |||
async clear() { | |||
if (this.editor !== undefined) { | |||
try { | |||
this.editor.setDecorations(this.decoration, []); | |||
this.highlightDecoration && this.editor.setDecorations(this.highlightDecoration, []); | |||
// I have no idea why the decorators sometimes don't get removed, but if they don't try again with a tiny delay | |||
if (this.highlightDecoration !== undefined) { | |||
await Functions.wait(1); | |||
if (this.highlightDecoration === undefined) return; | |||
this.editor.setDecorations(this.highlightDecoration, []); | |||
} | |||
} | |||
catch (ex) { } | |||
} | |||
// HACK: Until https://github.com/Microsoft/vscode/issues/11485 is fixed -- restore whitespace | |||
this.whitespaceController && await this.whitespaceController.restore(); | |||
} | |||
async reset() { | |||
await this.clear(); | |||
this._config = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
await this.provideAnnotation(this.editor === undefined ? undefined : this.editor.selection.active.line); | |||
} | |||
abstract async provideAnnotation(shaOrLine?: string | number): Promise<boolean>; | |||
abstract async selection(shaOrLine?: string | number): Promise<void>; | |||
abstract async validate(): Promise<boolean>; | |||
} |
@ -0,0 +1,189 @@ | |||
import { DecorationInstanceRenderOptions, DecorationOptions, ThemableDecorationRenderOptions } from 'vscode'; | |||
import { IThemeConfig, themeDefaults } from '../configuration'; | |||
import { CommitFormatter, GitCommit, GitService, GitUri, ICommitFormatOptions } from '../gitService'; | |||
import * as moment from 'moment'; | |||
interface IHeatmapConfig { | |||
enabled: boolean; | |||
location?: 'left' | 'right'; | |||
} | |||
interface IRenderOptions { | |||
uncommittedForegroundColor?: { | |||
dark: string; | |||
light: string; | |||
}; | |||
before?: DecorationInstanceRenderOptions & ThemableDecorationRenderOptions & { height?: string }; | |||
dark?: DecorationInstanceRenderOptions; | |||
light?: DecorationInstanceRenderOptions; | |||
} | |||
export const endOfLineIndex = 1000000; | |||
export class Annotations { | |||
static applyHeatmap(decoration: DecorationOptions, date: Date, now: moment.Moment) { | |||
const color = this._getHeatmapColor(now, date); | |||
(decoration.renderOptions!.before! as any).borderColor = color; | |||
} | |||
private static _getHeatmapColor(now: moment.Moment, date: Date) { | |||
const days = now.diff(moment(date), 'days'); | |||
if (days <= 2) return '#ffeca7'; | |||
if (days <= 7) return '#ffdd8c'; | |||
if (days <= 14) return '#ffdd7c'; | |||
if (days <= 30) return '#fba447'; | |||
if (days <= 60) return '#f68736'; | |||
if (days <= 90) return '#f37636'; | |||
if (days <= 180) return '#ca6632'; | |||
if (days <= 365) return '#c0513f'; | |||
if (days <= 730) return '#a2503a'; | |||
return '#793738'; | |||
} | |||
static async changesHover(commit: GitCommit, line: number, uri: GitUri, git: GitService): Promise<DecorationOptions> { | |||
let message: string | undefined = undefined; | |||
if (commit.isUncommitted) { | |||
const [previous, current] = await git.getDiffForLine(uri, line + uri.offset); | |||
message = CommitFormatter.toHoverDiff(commit, previous, current); | |||
} | |||
else if (commit.previousSha !== undefined) { | |||
const [previous, current] = await git.getDiffForLine(uri, line + uri.offset, commit.previousSha); | |||
message = CommitFormatter.toHoverDiff(commit, previous, current); | |||
} | |||
return { | |||
hoverMessage: message | |||
} as DecorationOptions; | |||
} | |||
static detailsHover(commit: GitCommit): DecorationOptions { | |||
const message = CommitFormatter.toHoverAnnotation(commit); | |||
return { | |||
hoverMessage: message | |||
} as DecorationOptions; | |||
} | |||
static gutter(commit: GitCommit, format: string, dateFormatOrFormatOptions: string | null | ICommitFormatOptions, renderOptions: IRenderOptions, compact: boolean): DecorationOptions { | |||
let content = `\u00a0${CommitFormatter.fromTemplate(format, commit, dateFormatOrFormatOptions)}\u00a0`; | |||
if (compact) { | |||
content = '\u00a0'.repeat(content.length); | |||
} | |||
return { | |||
renderOptions: { | |||
before: { | |||
...renderOptions.before, | |||
...{ | |||
contentText: content, | |||
margin: '0 26px 0 0' | |||
} | |||
}, | |||
dark: { | |||
before: commit.isUncommitted | |||
? { ...renderOptions.dark, ...{ color: renderOptions.uncommittedForegroundColor!.dark } } | |||
: { ...renderOptions.dark } | |||
}, | |||
light: { | |||
before: commit.isUncommitted | |||
? { ...renderOptions.light, ...{ color: renderOptions.uncommittedForegroundColor!.light } } | |||
: { ...renderOptions.light } | |||
} | |||
} as DecorationInstanceRenderOptions | |||
} as DecorationOptions; | |||
} | |||
static gutterRenderOptions(cfgTheme: IThemeConfig, heatmap: IHeatmapConfig): IRenderOptions { | |||
const cfgFileTheme = cfgTheme.annotations.file.gutter; | |||
let borderStyle = undefined; | |||
let borderWidth = undefined; | |||
if (heatmap.enabled) { | |||
borderStyle = 'solid'; | |||
borderWidth = heatmap.location === 'left' ? '0 0 0 2px' : '0 2px 0 0'; | |||
} | |||
return { | |||
uncommittedForegroundColor: { | |||
dark: cfgFileTheme.dark.uncommittedForegroundColor || cfgFileTheme.dark.foregroundColor || themeDefaults.annotations.file.gutter.dark.foregroundColor, | |||
light: cfgFileTheme.light.uncommittedForegroundColor || cfgFileTheme.light.foregroundColor || themeDefaults.annotations.file.gutter.light.foregroundColor | |||
}, | |||
before: { | |||
borderStyle: borderStyle, | |||
borderWidth: borderWidth, | |||
height: cfgFileTheme.separateLines ? 'calc(100% - 1px)' : '100%' | |||
}, | |||
dark: { | |||
backgroundColor: cfgFileTheme.dark.backgroundColor || undefined, | |||
color: cfgFileTheme.dark.foregroundColor || themeDefaults.annotations.file.gutter.dark.foregroundColor | |||
} as DecorationInstanceRenderOptions, | |||
light: { | |||
backgroundColor: cfgFileTheme.light.backgroundColor || undefined, | |||
color: cfgFileTheme.light.foregroundColor || themeDefaults.annotations.file.gutter.light.foregroundColor | |||
} as DecorationInstanceRenderOptions | |||
}; | |||
} | |||
static hover(commit: GitCommit, renderOptions: IRenderOptions, heatmap: boolean): DecorationOptions { | |||
return { | |||
hoverMessage: CommitFormatter.toHoverAnnotation(commit), | |||
renderOptions: heatmap ? { before: { ...renderOptions.before } } : undefined | |||
} as DecorationOptions; | |||
} | |||
static hoverRenderOptions(cfgTheme: IThemeConfig, heatmap: IHeatmapConfig): IRenderOptions { | |||
if (!heatmap.enabled) return { before: undefined }; | |||
return { | |||
before: { | |||
borderStyle: 'solid', | |||
borderWidth: '0 0 0 2px', | |||
contentText: '\u200B', | |||
height: cfgTheme.annotations.file.hover.separateLines ? 'calc(100% - 1px)' : '100%', | |||
margin: '0 26px 0 0' | |||
} | |||
} as IRenderOptions; | |||
} | |||
static trailing(commit: GitCommit, format: string, dateFormat: string | null, cfgTheme: IThemeConfig): DecorationOptions { | |||
const message = CommitFormatter.fromTemplate(format, commit, dateFormat); | |||
return { | |||
renderOptions: { | |||
after: { | |||
contentText: `\u00a0${message}\u00a0` | |||
}, | |||
dark: { | |||
after: { | |||
backgroundColor: cfgTheme.annotations.line.trailing.dark.backgroundColor || undefined, | |||
color: cfgTheme.annotations.line.trailing.dark.foregroundColor || themeDefaults.annotations.line.trailing.dark.foregroundColor | |||
} | |||
}, | |||
light: { | |||
after: { | |||
backgroundColor: cfgTheme.annotations.line.trailing.light.backgroundColor || undefined, | |||
color: cfgTheme.annotations.line.trailing.light.foregroundColor || themeDefaults.annotations.line.trailing.light.foregroundColor | |||
} | |||
} | |||
} as DecorationInstanceRenderOptions | |||
} as DecorationOptions; | |||
} | |||
static withRange(decoration: DecorationOptions, start?: number, end?: number): DecorationOptions { | |||
let range = decoration.range; | |||
if (start !== undefined) { | |||
range = range.with({ | |||
start: range.start.with({ character: start }) | |||
}); | |||
} | |||
if (end !== undefined) { | |||
range = range.with({ | |||
end: range.end.with({ character: end }) | |||
}); | |||
} | |||
return { ...decoration, ...{ range: range } }; | |||
} | |||
} |
@ -0,0 +1,82 @@ | |||
'use strict'; | |||
import { Iterables } from '../system'; | |||
import { ExtensionContext, Range, TextEditor, TextEditorDecorationType } from 'vscode'; | |||
import { AnnotationProviderBase } from './annotationProvider'; | |||
import { GitService, GitUri, IGitBlame } from '../gitService'; | |||
import { WhitespaceController } from './whitespaceController'; | |||
export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase { | |||
protected _blame: Promise<IGitBlame>; | |||
constructor(context: ExtensionContext, editor: TextEditor, decoration: TextEditorDecorationType, highlightDecoration: TextEditorDecorationType | undefined, whitespaceController: WhitespaceController | undefined, protected git: GitService, protected uri: GitUri) { | |||
super(context, editor, decoration, highlightDecoration, whitespaceController); | |||
this._blame = this.git.getBlameForFile(this.uri); | |||
} | |||
async selection(shaOrLine?: string | number, blame?: IGitBlame) { | |||
if (!this.highlightDecoration) return; | |||
if (blame === undefined) { | |||
blame = await this._blame; | |||
if (!blame || !blame.lines.length) return; | |||
} | |||
const offset = this.uri.offset; | |||
let sha: string | undefined = undefined; | |||
if (typeof shaOrLine === 'string') { | |||
sha = shaOrLine; | |||
} | |||
else if (typeof shaOrLine === 'number') { | |||
const line = shaOrLine - offset; | |||
if (line >= 0) { | |||
const commitLine = blame.lines[line]; | |||
sha = commitLine && commitLine.sha; | |||
} | |||
} | |||
else { | |||
sha = Iterables.first(blame.commits.values()).sha; | |||
} | |||
if (!sha) { | |||
this.editor.setDecorations(this.highlightDecoration, []); | |||
return; | |||
} | |||
const highlightDecorationRanges = blame.lines | |||
.filter(l => l.sha === sha) | |||
.map(l => this.editor.document.validateRange(new Range(l.line + offset, 0, l.line + offset, 1000000))); | |||
this.editor.setDecorations(this.highlightDecoration, highlightDecorationRanges); | |||
} | |||
async validate(): Promise<boolean> { | |||
const blame = await this._blame; | |||
return blame !== undefined && blame.lines.length !== 0; | |||
} | |||
protected async getBlame(requiresWhitespaceHack: boolean): Promise<IGitBlame | undefined> { | |||
let whitespacePromise: Promise<void> | undefined; | |||
// HACK: Until https://github.com/Microsoft/vscode/issues/11485 is fixed -- override whitespace (turn off) | |||
if (requiresWhitespaceHack) { | |||
whitespacePromise = this.whitespaceController && this.whitespaceController.override(); | |||
} | |||
let blame: IGitBlame; | |||
if (whitespacePromise) { | |||
[blame] = await Promise.all([this._blame, whitespacePromise]); | |||
} | |||
else { | |||
blame = await this._blame; | |||
} | |||
if (!blame || !blame.lines.length) { | |||
this.whitespaceController && await this.whitespaceController.restore(); | |||
return undefined; | |||
} | |||
return blame; | |||
} | |||
} |
@ -0,0 +1,69 @@ | |||
'use strict'; | |||
import { DecorationOptions, ExtensionContext, Position, Range, TextEditor, TextEditorDecorationType } from 'vscode'; | |||
import { AnnotationProviderBase } from './annotationProvider'; | |||
import { GitService, GitUri } from '../gitService'; | |||
import { WhitespaceController } from './whitespaceController'; | |||
export class DiffAnnotationProvider extends AnnotationProviderBase { | |||
constructor(context: ExtensionContext, editor: TextEditor, decoration: TextEditorDecorationType, highlightDecoration: TextEditorDecorationType | undefined, whitespaceController: WhitespaceController | undefined, private git: GitService, private uri: GitUri) { | |||
super(context, editor, decoration, highlightDecoration, whitespaceController); | |||
} | |||
async provideAnnotation(shaOrLine?: string | number): Promise<boolean> { | |||
// let sha1: string | undefined = undefined; | |||
// let sha2: string | undefined = undefined; | |||
// if (shaOrLine === undefined) { | |||
// const commit = await this.git.getLogCommit(this.uri.repoPath, this.uri.fsPath, { previous: true }); | |||
// if (commit === undefined) return false; | |||
// sha1 = commit.previousSha; | |||
// } | |||
// else if (typeof shaOrLine === 'string') { | |||
// sha1 = shaOrLine; | |||
// } | |||
// else { | |||
// const blame = await this.git.getBlameForLine(this.uri, shaOrLine); | |||
// if (blame === undefined) return false; | |||
// sha1 = blame.commit.previousSha; | |||
// sha2 = blame.commit.sha; | |||
// } | |||
// if (sha1 === undefined) return false; | |||
const commit = await this.git.getLogCommit(this.uri.repoPath, this.uri.fsPath, { previous: true }); | |||
if (commit === undefined) return false; | |||
const diff = await this.git.getDiffForFile(this.uri, commit.previousSha); | |||
if (diff === undefined) return false; | |||
const decorators: DecorationOptions[] = []; | |||
for (const chunk of diff.chunks) { | |||
let count = chunk.currentStart - 2; | |||
for (const change of chunk.current) { | |||
if (change === undefined) continue; | |||
count++; | |||
if (change.state === 'unchanged') continue; | |||
decorators.push({ | |||
range: new Range(new Position(count, 0), new Position(count, 0)) | |||
} as DecorationOptions); | |||
} | |||
} | |||
this.editor.setDecorations(this.decoration, decorators); | |||
return true; | |||
} | |||
async selection(shaOrLine?: string | number): Promise<void> { | |||
} | |||
async validate(): Promise<boolean> { | |||
return true; | |||
} | |||
} |
@ -0,0 +1,76 @@ | |||
'use strict'; | |||
import { Strings } from '../system'; | |||
import { DecorationOptions, Range } from 'vscode'; | |||
import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; | |||
import { Annotations, endOfLineIndex } from './annotations'; | |||
import { FileAnnotationType } from '../configuration'; | |||
import { ICommitFormatOptions } from '../gitService'; | |||
import * as moment from 'moment'; | |||
export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { | |||
async provideAnnotation(shaOrLine?: string | number, type?: FileAnnotationType): Promise<boolean> { | |||
this.annotationType = FileAnnotationType.Gutter; | |||
const blame = await this.getBlame(true); | |||
if (blame === undefined) return false; | |||
const cfg = this._config.annotations.file.gutter; | |||
// Precalculate the formatting options so we don't need to do it on each iteration | |||
const tokenOptions = Strings.getTokensFromTemplate(cfg.format) | |||
.reduce((map, token) => { | |||
map[token.key] = token.options; | |||
return map; | |||
}, {} as { [token: string]: ICommitFormatOptions }); | |||
const options: ICommitFormatOptions = { | |||
dateFormat: cfg.dateFormat, | |||
tokenOptions: tokenOptions | |||
}; | |||
const now = moment(); | |||
const offset = this.uri.offset; | |||
let previousLine: string | undefined = undefined; | |||
const renderOptions = Annotations.gutterRenderOptions(this._config.theme, cfg.heatmap); | |||
const decorations: DecorationOptions[] = []; | |||
for (const l of blame.lines) { | |||
const commit = blame.commits.get(l.sha); | |||
if (commit === undefined) continue; | |||
const line = l.line + offset; | |||
const gutter = Annotations.gutter(commit, cfg.format, options, renderOptions, cfg.compact && previousLine === l.sha); | |||
if (cfg.compact) { | |||
const isEmptyOrWhitespace = this.document.lineAt(line).isEmptyOrWhitespace; | |||
previousLine = isEmptyOrWhitespace ? undefined : l.sha; | |||
} | |||
if (cfg.heatmap.enabled) { | |||
Annotations.applyHeatmap(gutter, commit.date, now); | |||
} | |||
const firstNonWhitespace = this.editor.document.lineAt(line).firstNonWhitespaceCharacterIndex; | |||
gutter.range = this.editor.document.validateRange(new Range(line, 0, line, firstNonWhitespace)); | |||
decorations.push(gutter); | |||
if (cfg.hover.details) { | |||
const details = Annotations.detailsHover(commit); | |||
details.range = cfg.hover.wholeLine | |||
? this.editor.document.validateRange(new Range(line, 0, line, endOfLineIndex)) | |||
: gutter.range; | |||
decorations.push(details); | |||
} | |||
} | |||
if (decorations.length) { | |||
this.editor.setDecorations(this.decoration, decorations); | |||
} | |||
this.selection(shaOrLine, blame); | |||
return true; | |||
} | |||
} |
@ -0,0 +1,49 @@ | |||
'use strict'; | |||
import { DecorationOptions, Range } from 'vscode'; | |||
import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; | |||
import { Annotations, endOfLineIndex } from './annotations'; | |||
import { FileAnnotationType } from '../configuration'; | |||
import * as moment from 'moment'; | |||
export class HoverBlameAnnotationProvider extends BlameAnnotationProviderBase { | |||
async provideAnnotation(shaOrLine?: string | number): Promise<boolean> { | |||
this.annotationType = FileAnnotationType.Hover; | |||
const blame = await this.getBlame(this._config.annotations.file.hover.heatmap.enabled); | |||
if (blame === undefined) return false; | |||
const cfg = this._config.annotations.file.hover; | |||
const now = moment(); | |||
const offset = this.uri.offset; | |||
const renderOptions = Annotations.hoverRenderOptions(this._config.theme, cfg.heatmap); | |||
const decorations: DecorationOptions[] = []; | |||
for (const l of blame.lines) { | |||
const commit = blame.commits.get(l.sha); | |||
if (commit === undefined) continue; | |||
const line = l.line + offset; | |||
const hover = Annotations.hover(commit, renderOptions, cfg.heatmap.enabled); | |||
const endIndex = cfg.wholeLine ? endOfLineIndex : this.editor.document.lineAt(line).firstNonWhitespaceCharacterIndex; | |||
hover.range = this.editor.document.validateRange(new Range(line, 0, line, endIndex)); | |||
if (cfg.heatmap.enabled) { | |||
Annotations.applyHeatmap(hover, commit.date, now); | |||
} | |||
decorations.push(hover); | |||
} | |||
if (decorations.length) { | |||
this.editor.setDecorations(this.decoration, decorations); | |||
} | |||
this.selection(shaOrLine, blame); | |||
return true; | |||
} | |||
} |
@ -1,391 +0,0 @@ | |||
'use strict'; | |||
import { Functions, Objects } from './system'; | |||
import { DecorationInstanceRenderOptions, DecorationOptions, DecorationRenderOptions, Disposable, ExtensionContext, Range, StatusBarAlignment, StatusBarItem, TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent, window, workspace } from 'vscode'; | |||
import { BlameAnnotationController } from './blameAnnotationController'; | |||
import { BlameAnnotationFormat, BlameAnnotationFormatter } from './blameAnnotationFormatter'; | |||
import { Commands } from './commands'; | |||
import { TextEditorComparer } from './comparers'; | |||
import { IBlameConfig, IConfig, StatusBarCommand } from './configuration'; | |||
import { DocumentSchemes, ExtensionKey } from './constants'; | |||
import { BlameabilityChangeEvent, GitCommit, GitContextTracker, GitService, GitUri, IGitCommitLine } from './gitService'; | |||
import * as moment from 'moment'; | |||
const activeLineDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ | |||
after: { | |||
margin: '0 0 0 4em' | |||
} | |||
} as DecorationRenderOptions); | |||
export class BlameActiveLineController extends Disposable { | |||
private _activeEditorLineDisposable: Disposable | undefined; | |||
private _blameable: boolean; | |||
private _config: IConfig; | |||
private _currentLine: number = -1; | |||
private _disposable: Disposable; | |||
private _editor: TextEditor | undefined; | |||
private _statusBarItem: StatusBarItem | undefined; | |||
private _updateBlameDebounced: (line: number, editor: TextEditor) => Promise<void>; | |||
private _uri: GitUri; | |||
constructor(context: ExtensionContext, private git: GitService, private gitContextTracker: GitContextTracker, private annotationController: BlameAnnotationController) { | |||
super(() => this.dispose()); | |||
this._updateBlameDebounced = Functions.debounce(this._updateBlame, 250); | |||
this._onConfigurationChanged(); | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); | |||
subscriptions.push(git.onDidChangeGitCache(this._onGitCacheChanged, this)); | |||
subscriptions.push(annotationController.onDidToggleBlameAnnotations(this._onBlameAnnotationToggled, this)); | |||
this._disposable = Disposable.from(...subscriptions); | |||
} | |||
dispose() { | |||
this._editor && this._editor.setDecorations(activeLineDecoration, []); | |||
this._activeEditorLineDisposable && this._activeEditorLineDisposable.dispose(); | |||
this._statusBarItem && this._statusBarItem.dispose(); | |||
this._disposable && this._disposable.dispose(); | |||
} | |||
private _onConfigurationChanged() { | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
let changed = false; | |||
if (!Objects.areEquivalent(cfg.statusBar, this._config && this._config.statusBar)) { | |||
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 (!cfg.statusBar.enabled && this._statusBarItem) { | |||
this._statusBarItem.dispose(); | |||
this._statusBarItem = undefined; | |||
} | |||
} | |||
if (!Objects.areEquivalent(cfg.blame.annotation.activeLine, this._config && this._config.blame.annotation.activeLine)) { | |||
changed = true; | |||
if (cfg.blame.annotation.activeLine !== 'off' && this._editor) { | |||
this._editor.setDecorations(activeLineDecoration, []); | |||
} | |||
} | |||
if (!Objects.areEquivalent(cfg.blame.annotation.activeLineDarkColor, this._config && this._config.blame.annotation.activeLineDarkColor) || | |||
!Objects.areEquivalent(cfg.blame.annotation.activeLineLightColor, this._config && this._config.blame.annotation.activeLineLightColor)) { | |||
changed = true; | |||
} | |||
this._config = cfg; | |||
if (!changed) return; | |||
const trackActiveLine = cfg.statusBar.enabled || cfg.blame.annotation.activeLine !== 'off'; | |||
if (trackActiveLine && !this._activeEditorLineDisposable) { | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(window.onDidChangeActiveTextEditor(this._onActiveTextEditorChanged, this)); | |||
subscriptions.push(window.onDidChangeTextEditorSelection(this._onTextEditorSelectionChanged, this)); | |||
subscriptions.push(this.gitContextTracker.onDidBlameabilityChange(this._onBlameabilityChanged, this)); | |||
this._activeEditorLineDisposable = Disposable.from(...subscriptions); | |||
} | |||
else if (!trackActiveLine && this._activeEditorLineDisposable) { | |||
this._activeEditorLineDisposable.dispose(); | |||
this._activeEditorLineDisposable = undefined; | |||
} | |||
this._onActiveTextEditorChanged(window.activeTextEditor); | |||
} | |||
private isEditorBlameable(editor: TextEditor | undefined): boolean { | |||
if (editor === undefined || editor.document === undefined) return false; | |||
if (!this.git.isTrackable(editor.document.uri)) return false; | |||
if (editor.document.isUntitled && editor.document.uri.scheme === DocumentSchemes.File) return false; | |||
return this.git.isEditorBlameable(editor); | |||
} | |||
private async _onActiveTextEditorChanged(editor: TextEditor | undefined) { | |||
this._currentLine = -1; | |||
const previousEditor = this._editor; | |||
previousEditor && previousEditor.setDecorations(activeLineDecoration, []); | |||
if (editor === undefined || !this.isEditorBlameable(editor)) { | |||
this.clear(editor); | |||
this._editor = undefined; | |||
return; | |||
} | |||
this._blameable = editor !== undefined && editor.document !== undefined && !editor.document.isDirty; | |||
this._editor = editor; | |||
this._uri = await GitUri.fromUri(editor.document.uri, this.git); | |||
const maxLines = this._config.advanced.caching.statusBar.maxLines; | |||
// If caching is on and the file is small enough -- kick off a blame for the whole file | |||
if (this._config.advanced.caching.enabled && (maxLines <= 0 || editor.document.lineCount <= maxLines)) { | |||
this.git.getBlameForFile(this._uri); | |||
} | |||
this._updateBlameDebounced(editor.selection.active.line, editor); | |||
} | |||
private _onBlameabilityChanged(e: BlameabilityChangeEvent) { | |||
this._blameable = e.blameable; | |||
if (!e.blameable || !this._editor) { | |||
this.clear(e.editor); | |||
return; | |||
} | |||
// Make sure this is for the editor we are tracking | |||
if (!TextEditorComparer.equals(this._editor, e.editor)) return; | |||
this._updateBlameDebounced(this._editor.selection.active.line, this._editor); | |||
} | |||
private _onBlameAnnotationToggled() { | |||
this._onActiveTextEditorChanged(window.activeTextEditor); | |||
} | |||
private _onGitCacheChanged() { | |||
this._onActiveTextEditorChanged(window.activeTextEditor); | |||
} | |||
private async _onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent): Promise<void> { | |||
// Make sure this is for the editor we are tracking | |||
if (!this._blameable || !TextEditorComparer.equals(this._editor, e.textEditor)) return; | |||
const line = e.selections[0].active.line; | |||
if (line === this._currentLine) return; | |||
this._currentLine = line; | |||
if (!this._uri && e.textEditor) { | |||
this._uri = await GitUri.fromUri(e.textEditor.document.uri, this.git); | |||
} | |||
this._updateBlameDebounced(line, e.textEditor); | |||
} | |||
private async _updateBlame(line: number, editor: TextEditor) { | |||
line = line - this._uri.offset; | |||
let commit: GitCommit | undefined = undefined; | |||
let commitLine: IGitCommitLine | undefined = undefined; | |||
// Since blame information isn't valid when there are unsaved changes -- don't show any status | |||
if (this._blameable && line >= 0) { | |||
const blameLine = await this.git.getBlameForLine(this._uri, line); | |||
commitLine = blameLine === undefined ? undefined : blameLine.line; | |||
commit = blameLine === undefined ? undefined : blameLine.commit; | |||
} | |||
if (commit !== undefined && commitLine !== undefined) { | |||
this.show(commit, commitLine, editor); | |||
} | |||
else { | |||
this.clear(editor); | |||
} | |||
} | |||
clear(editor: TextEditor | undefined, previousEditor?: TextEditor) { | |||
editor && editor.setDecorations(activeLineDecoration, []); | |||
// I have no idea why the decorators sometimes don't get removed, but if they don't try again with a tiny delay | |||
if (editor) { | |||
setTimeout(() => editor.setDecorations(activeLineDecoration, []), 1); | |||
} | |||
this._statusBarItem && this._statusBarItem.hide(); | |||
} | |||
async show(commit: GitCommit, blameLine: IGitCommitLine, editor: TextEditor) { | |||
// I have no idea why I need this protection -- but it happens | |||
if (!editor.document) return; | |||
if (this._config.statusBar.enabled && this._statusBarItem !== undefined) { | |||
switch (this._config.statusBar.date) { | |||
case 'off': | |||
this._statusBarItem.text = `$(git-commit) ${commit.author}`; | |||
break; | |||
case 'absolute': | |||
const dateFormat = this._config.statusBar.dateFormat || 'MMMM Do, YYYY h:MMa'; | |||
let date: string; | |||
try { | |||
date = moment(commit.date).format(dateFormat); | |||
} catch (ex) { | |||
date = moment(commit.date).format('MMMM Do, YYYY h:MMa'); | |||
} | |||
this._statusBarItem.text = `$(git-commit) ${commit.author}, ${date}`; | |||
break; | |||
default: | |||
this._statusBarItem.text = `$(git-commit) ${commit.author}, ${moment(commit.date).fromNow()}`; | |||
break; | |||
} | |||
switch (this._config.statusBar.command) { | |||
case StatusBarCommand.BlameAnnotate: | |||
this._statusBarItem.tooltip = 'Toggle Blame Annotations'; | |||
break; | |||
case StatusBarCommand.ShowBlameHistory: | |||
this._statusBarItem.tooltip = 'Open Blame History Explorer'; | |||
break; | |||
case StatusBarCommand.ShowFileHistory: | |||
this._statusBarItem.tooltip = 'Open File History Explorer'; | |||
break; | |||
case StatusBarCommand.DiffWithPrevious: | |||
this._statusBarItem.command = Commands.DiffLineWithPrevious; | |||
this._statusBarItem.tooltip = 'Compare File with Previous'; | |||
break; | |||
case StatusBarCommand.DiffWithWorking: | |||
this._statusBarItem.command = Commands.DiffLineWithWorking; | |||
this._statusBarItem.tooltip = 'Compare File with Working Tree'; | |||
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(); | |||
} | |||
if (this._config.blame.annotation.activeLine !== 'off') { | |||
const activeLine = this._config.blame.annotation.activeLine; | |||
const offset = this._uri.offset; | |||
const cfg = { | |||
annotation: { | |||
sha: true, | |||
author: this._config.statusBar.enabled ? false : this._config.blame.annotation.author, | |||
date: this._config.statusBar.enabled ? 'off' : this._config.blame.annotation.date, | |||
message: true | |||
} | |||
} as IBlameConfig; | |||
const annotation = BlameAnnotationFormatter.getAnnotation(cfg, commit, BlameAnnotationFormat.Unconstrained); | |||
// Get the full commit message -- since blame only returns the summary | |||
let logCommit: GitCommit | undefined = undefined; | |||
if (!commit.isUncommitted) { | |||
logCommit = await this.git.getLogCommit(this._uri.repoPath, this._uri.fsPath, commit.sha); | |||
} | |||
// I have no idea why I need this protection -- but it happens | |||
if (!editor.document) return; | |||
let hoverMessage: string | string[] | undefined = undefined; | |||
if (activeLine !== 'inline') { | |||
// If the messages match (or we couldn't find the log), then this is a possible duplicate annotation | |||
const possibleDuplicate = !logCommit || logCommit.message === commit.message; | |||
// If we don't have a possible dupe or we aren't showing annotations get the hover message | |||
if (!commit.isUncommitted && (!possibleDuplicate || !this.annotationController.isAnnotating(editor))) { | |||
hoverMessage = BlameAnnotationFormatter.getAnnotationHover(cfg, blameLine, logCommit || commit); | |||
if (commit.previousSha !== undefined) { | |||
const changes = await this.git.getDiffForLine(this._uri, blameLine.line + offset, commit.previousSha); | |||
if (changes !== undefined) { | |||
let previous = changes[0]; | |||
if (previous !== undefined) { | |||
previous = previous.replace(/\n/g, '\`\n>\n> \`').trim(); | |||
hoverMessage += `\n\n---\n\`\`\`\n${previous}\n\`\`\``; | |||
} | |||
} | |||
} | |||
} | |||
else if (commit.isUncommitted) { | |||
const changes = await this.git.getDiffForLine(this._uri, blameLine.line + offset); | |||
if (changes !== undefined) { | |||
let previous = changes[0]; | |||
if (previous !== undefined) { | |||
previous = previous.replace(/\n/g, '\`\n>\n> \`').trim(); | |||
hoverMessage = `\`${'0'.repeat(8)}\` __Uncommitted change__\n\n---\n\`\`\`\n${previous}\n\`\`\``; | |||
} | |||
} | |||
} | |||
} | |||
let decorationOptions: [DecorationOptions] | undefined = undefined; | |||
switch (activeLine) { | |||
case 'both': | |||
case 'inline': | |||
const range = editor.document.validateRange(new Range(blameLine.line + offset, 0, blameLine.line + offset, 1000000)); | |||
decorationOptions = [ | |||
{ | |||
range: range.with({ | |||
start: range.start.with({ | |||
character: range.end.character | |||
}) | |||
}), | |||
hoverMessage: hoverMessage, | |||
renderOptions: { | |||
after: { | |||
contentText: annotation | |||
}, | |||
dark: { | |||
after: { | |||
color: this._config.blame.annotation.activeLineDarkColor || 'rgba(153, 153, 153, 0.35)' | |||
} | |||
}, | |||
light: { | |||
after: { | |||
color: this._config.blame.annotation.activeLineLightColor || 'rgba(153, 153, 153, 0.35)' | |||
} | |||
} | |||
} as DecorationInstanceRenderOptions | |||
} as DecorationOptions | |||
]; | |||
if (activeLine === 'both') { | |||
// Add a hover decoration to the area between the start of the line and the first non-whitespace character | |||
decorationOptions.push({ | |||
range: range.with({ | |||
end: range.end.with({ | |||
character: editor.document.lineAt(range.end.line).firstNonWhitespaceCharacterIndex | |||
}) | |||
}), | |||
hoverMessage: hoverMessage | |||
} as DecorationOptions); | |||
} | |||
break; | |||
case 'hover': | |||
decorationOptions = [ | |||
{ | |||
range: editor.document.validateRange(new Range(blameLine.line + offset, 0, blameLine.line + offset, 1000000)), | |||
hoverMessage: hoverMessage | |||
} as DecorationOptions | |||
]; | |||
break; | |||
} | |||
if (decorationOptions !== undefined) { | |||
editor.setDecorations(activeLineDecoration, decorationOptions); | |||
} | |||
} | |||
} | |||
} |
@ -1,271 +0,0 @@ | |||
'use strict'; | |||
import { Functions } from './system'; | |||
import { DecorationRenderOptions, Disposable, Event, EventEmitter, ExtensionContext, OverviewRulerLane, TextDocument, TextDocumentChangeEvent, TextEditor, TextEditorDecorationType, TextEditorViewColumnChangeEvent, window, workspace } from 'vscode'; | |||
import { BlameAnnotationProvider } from './blameAnnotationProvider'; | |||
import { TextDocumentComparer, TextEditorComparer } from './comparers'; | |||
import { IBlameConfig } from './configuration'; | |||
import { ExtensionKey } from './constants'; | |||
import { BlameabilityChangeEvent, GitContextTracker, GitService, GitUri } from './gitService'; | |||
import { Logger } from './logger'; | |||
import { WhitespaceController } from './whitespaceController'; | |||
export const BlameDecorations = { | |||
annotation: window.createTextEditorDecorationType({ | |||
before: { | |||
margin: '0 1.75em 0 0' | |||
}, | |||
after: { | |||
margin: '0 0 0 4em' | |||
} | |||
} as DecorationRenderOptions), | |||
highlight: undefined as TextEditorDecorationType | undefined | |||
}; | |||
export class BlameAnnotationController extends Disposable { | |||
private _onDidToggleBlameAnnotations = new EventEmitter<void>(); | |||
get onDidToggleBlameAnnotations(): Event<void> { | |||
return this._onDidToggleBlameAnnotations.event; | |||
} | |||
private _annotationProviders: Map<number, BlameAnnotationProvider> = new Map(); | |||
private _blameAnnotationsDisposable: Disposable | undefined; | |||
private _config: IBlameConfig; | |||
private _disposable: Disposable; | |||
private _whitespaceController: WhitespaceController | undefined; | |||
constructor(private context: ExtensionContext, private git: GitService, private gitContextTracker: GitContextTracker) { | |||
super(() => this.dispose()); | |||
this._onConfigurationChanged(); | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); | |||
this._disposable = Disposable.from(...subscriptions); | |||
} | |||
dispose() { | |||
this._annotationProviders.forEach(async (p, i) => await this.clear(i)); | |||
BlameDecorations.annotation && BlameDecorations.annotation.dispose(); | |||
BlameDecorations.highlight && BlameDecorations.highlight.dispose(); | |||
this._blameAnnotationsDisposable && this._blameAnnotationsDisposable.dispose(); | |||
this._whitespaceController && this._whitespaceController.dispose(); | |||
this._disposable && this._disposable.dispose(); | |||
} | |||
private _onConfigurationChanged() { | |||
let toggleWhitespace = workspace.getConfiguration(`${ExtensionKey}.advanced.toggleWhitespace`).get<boolean>('enabled'); | |||
if (!toggleWhitespace) { | |||
// Until https://github.com/Microsoft/vscode/issues/11485 is fixed we need to toggle whitespace for non-monospace fonts and ligatures | |||
// TODO: detect monospace font | |||
toggleWhitespace = workspace.getConfiguration('editor').get<boolean>('fontLigatures'); | |||
} | |||
if (toggleWhitespace && !this._whitespaceController) { | |||
this._whitespaceController = new WhitespaceController(); | |||
} | |||
else if (!toggleWhitespace && this._whitespaceController) { | |||
this._whitespaceController.dispose(); | |||
this._whitespaceController = undefined; | |||
} | |||
const cfg = workspace.getConfiguration(ExtensionKey).get<IBlameConfig>('blame')!; | |||
if (cfg.annotation.highlight !== (this._config && this._config.annotation.highlight)) { | |||
BlameDecorations.highlight && BlameDecorations.highlight.dispose(); | |||
switch (cfg.annotation.highlight) { | |||
case 'gutter': | |||
BlameDecorations.highlight = window.createTextEditorDecorationType({ | |||
dark: { | |||
gutterIconPath: this.context.asAbsolutePath('images/blame-dark.svg'), | |||
overviewRulerColor: 'rgba(255, 255, 255, 0.75)' | |||
}, | |||
light: { | |||
gutterIconPath: this.context.asAbsolutePath('images/blame-light.svg'), | |||
overviewRulerColor: 'rgba(0, 0, 0, 0.75)' | |||
}, | |||
gutterIconSize: 'contain', | |||
overviewRulerLane: OverviewRulerLane.Right | |||
}); | |||
break; | |||
case 'line': | |||
BlameDecorations.highlight = window.createTextEditorDecorationType({ | |||
dark: { | |||
backgroundColor: 'rgba(255, 255, 255, 0.15)', | |||
overviewRulerColor: 'rgba(255, 255, 255, 0.75)' | |||
}, | |||
light: { | |||
backgroundColor: 'rgba(0, 0, 0, 0.15)', | |||
overviewRulerColor: 'rgba(0, 0, 0, 0.75)' | |||
}, | |||
overviewRulerLane: OverviewRulerLane.Right, | |||
isWholeLine: true | |||
}); | |||
break; | |||
case 'both': | |||
BlameDecorations.highlight = window.createTextEditorDecorationType({ | |||
dark: { | |||
backgroundColor: 'rgba(255, 255, 255, 0.15)', | |||
gutterIconPath: this.context.asAbsolutePath('images/blame-dark.svg'), | |||
overviewRulerColor: 'rgba(255, 255, 255, 0.75)' | |||
}, | |||
light: { | |||
backgroundColor: 'rgba(0, 0, 0, 0.15)', | |||
gutterIconPath: this.context.asAbsolutePath('images/blame-light.svg'), | |||
overviewRulerColor: 'rgba(0, 0, 0, 0.75)' | |||
}, | |||
gutterIconSize: 'contain', | |||
overviewRulerLane: OverviewRulerLane.Right, | |||
isWholeLine: true | |||
}); | |||
break; | |||
default: | |||
BlameDecorations.highlight = undefined; | |||
break; | |||
} | |||
} | |||
this._config = cfg; | |||
} | |||
async clear(column: number) { | |||
const provider = this._annotationProviders.get(column); | |||
if (!provider) return; | |||
this._annotationProviders.delete(column); | |||
await provider.dispose(); | |||
if (this._annotationProviders.size === 0) { | |||
Logger.log(`Remove listener registrations for blame annotations`); | |||
this._blameAnnotationsDisposable && this._blameAnnotationsDisposable.dispose(); | |||
this._blameAnnotationsDisposable = undefined; | |||
} | |||
this._onDidToggleBlameAnnotations.fire(); | |||
} | |||
async showBlameAnnotation(editor: TextEditor, shaOrLine?: string | number): Promise<boolean> { | |||
if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; | |||
const currentProvider = this._annotationProviders.get(editor.viewColumn || -1); | |||
if (currentProvider && TextEditorComparer.equals(currentProvider.editor, editor)) { | |||
await currentProvider.setSelection(shaOrLine); | |||
return true; | |||
} | |||
const gitUri = await GitUri.fromUri(editor.document.uri, this.git); | |||
const provider = new BlameAnnotationProvider(this.context, this.git, this._whitespaceController, editor, gitUri); | |||
if (!await provider.supportsBlame()) return false; | |||
if (currentProvider) { | |||
await this.clear(currentProvider.editor.viewColumn || -1); | |||
} | |||
if (!this._blameAnnotationsDisposable && this._annotationProviders.size === 0) { | |||
Logger.log(`Add listener registrations for blame annotations`); | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(window.onDidChangeVisibleTextEditors(Functions.debounce(this._onVisibleTextEditorsChanged, 100), this)); | |||
subscriptions.push(window.onDidChangeTextEditorViewColumn(this._onTextEditorViewColumnChanged, this)); | |||
subscriptions.push(workspace.onDidChangeTextDocument(this._onTextDocumentChanged, this)); | |||
subscriptions.push(workspace.onDidCloseTextDocument(this._onTextDocumentClosed, this)); | |||
subscriptions.push(this.gitContextTracker.onDidBlameabilityChange(this._onBlameabilityChanged, this)); | |||
this._blameAnnotationsDisposable = Disposable.from(...subscriptions); | |||
} | |||
this._annotationProviders.set(editor.viewColumn || -1, provider); | |||
if (await provider.provideBlameAnnotation(shaOrLine)) { | |||
this._onDidToggleBlameAnnotations.fire(); | |||
return true; | |||
} | |||
return false; | |||
} | |||
isAnnotating(editor: TextEditor): boolean { | |||
if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; | |||
return !!this._annotationProviders.get(editor.viewColumn || -1); | |||
} | |||
async toggleBlameAnnotation(editor: TextEditor, shaOrLine?: string | number): Promise<boolean> { | |||
if (!editor || !editor.document || !this.git.isEditorBlameable(editor)) return false; | |||
const provider = this._annotationProviders.get(editor.viewColumn || -1); | |||
if (!provider) return this.showBlameAnnotation(editor, shaOrLine); | |||
await this.clear(provider.editor.viewColumn || -1); | |||
return false; | |||
} | |||
private _onBlameabilityChanged(e: BlameabilityChangeEvent) { | |||
if (e.blameable || !e.editor) return; | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextDocumentComparer.equals(p.document, e.editor.document)) continue; | |||
Logger.log('BlameabilityChanged:', `Clear blame annotations for column ${key}`); | |||
this.clear(key); | |||
} | |||
} | |||
private _onTextDocumentChanged(e: TextDocumentChangeEvent) { | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextDocumentComparer.equals(p.document, e.document)) continue; | |||
// We have to defer because isDirty is not reliable inside this event | |||
setTimeout(() => { | |||
// If the document is dirty all is fine, just kick out since the GitContextTracker will handle it | |||
if (e.document.isDirty) return; | |||
// If the document isn't dirty, it is very likely this event was triggered by an outside edit of this document | |||
// Which means the document has been reloaded and the blame annotations have been removed, so we need to update (clear) our state tracking | |||
Logger.log('TextDocumentChanged:', `Clear blame annotations for column ${key}`); | |||
this.clear(key); | |||
}, 1); | |||
} | |||
} | |||
private _onTextDocumentClosed(e: TextDocument) { | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextDocumentComparer.equals(p.document, e)) continue; | |||
Logger.log('TextDocumentClosed:', `Clear blame annotations for column ${key}`); | |||
this.clear(key); | |||
} | |||
} | |||
private async _onTextEditorViewColumnChanged(e: TextEditorViewColumnChangeEvent) { | |||
const viewColumn = e.viewColumn || -1; | |||
Logger.log('TextEditorViewColumnChanged:', `Clear blame annotations for column ${viewColumn}`); | |||
await this.clear(viewColumn); | |||
for (const [key, p] of this._annotationProviders) { | |||
if (!TextEditorComparer.equals(p.editor, e.textEditor)) continue; | |||
Logger.log('TextEditorViewColumnChanged:', `Clear blame annotations for column ${key}`); | |||
await this.clear(key); | |||
} | |||
} | |||
private async _onVisibleTextEditorsChanged(e: TextEditor[]) { | |||
if (e.every(_ => _.document.uri.scheme === 'inmemory')) return; | |||
for (const [key, p] of this._annotationProviders) { | |||
if (e.some(_ => TextEditorComparer.equals(p.editor, _))) continue; | |||
Logger.log('VisibleTextEditorsChanged:', `Clear blame annotations for column ${key}`); | |||
this.clear(key); | |||
} | |||
} | |||
} |
@ -1,113 +0,0 @@ | |||
'use strict'; | |||
import { IBlameConfig } from './configuration'; | |||
import { GitCommit, IGitCommitLine } from './gitService'; | |||
import * as moment from 'moment'; | |||
export const defaultAbsoluteDateLength = 10; | |||
export const defaultRelativeDateLength = 13; | |||
export const defaultAuthorLength = 16; | |||
export const defaultMessageLength = 32; | |||
export enum BlameAnnotationFormat { | |||
Constrained, | |||
Unconstrained | |||
} | |||
export class BlameAnnotationFormatter { | |||
static getAnnotation(config: IBlameConfig, commit: GitCommit, format: BlameAnnotationFormat) { | |||
const sha = commit.shortSha; | |||
let message = this.getMessage(config, commit, format === BlameAnnotationFormat.Unconstrained ? 0 : defaultMessageLength); | |||
if (format === BlameAnnotationFormat.Unconstrained) { | |||
const authorAndDate = this.getAuthorAndDate(config, commit, config.annotation.dateFormat || 'MMMM Do, YYYY h:MMa'); | |||
if (config.annotation.sha) { | |||
message = `${sha}${(authorAndDate ? `\u00a0\u2022\u00a0${authorAndDate}` : '')}${(message ? `\u00a0\u2022\u00a0${message}` : '')}`; | |||
} | |||
else if (config.annotation.author || config.annotation.date) { | |||
message = `${authorAndDate}${(message ? `\u00a0\u2022\u00a0${message}` : '')}`; | |||
} | |||
return message; | |||
} | |||
const author = this.getAuthor(config, commit, defaultAuthorLength); | |||
const date = this.getDate(config, commit, config.annotation.dateFormat || 'MM/DD/YYYY', true); | |||
if (config.annotation.sha) { | |||
message = `${sha}${(author ? `\u00a0\u2022\u00a0${author}` : '')}${(date ? `\u00a0\u2022\u00a0${date}` : '')}${(message ? `\u00a0\u2022\u00a0${message}` : '')}`; | |||
} | |||
else if (config.annotation.author) { | |||
message = `${author}${(date ? `\u00a0\u2022\u00a0${date}` : '')}${(message ? `\u00a0\u2022\u00a0${message}` : '')}`; | |||
} | |||
else if (config.annotation.date) { | |||
message = `${date}${(message ? `\u00a0\u2022\u00a0${message}` : '')}`; | |||
} | |||
return message; | |||
} | |||
static getAnnotationHover(config: IBlameConfig, line: IGitCommitLine, commit: GitCommit): string | string[] { | |||
const message = `> \`${commit.message.replace(/\n/g, '\`\n>\n> \`')}\``; | |||
if (commit.isUncommitted) { | |||
return `\`${'0'.repeat(8)}\` __Uncommitted change__`; | |||
} | |||
return `\`${commit.shortSha}\` __${commit.author}__, ${moment(commit.date).fromNow()} _(${moment(commit.date).format(config.annotation.dateFormat || 'MMMM Do, YYYY h:MMa')})_ \n\n${message}`; | |||
} | |||
static getAuthorAndDate(config: IBlameConfig, commit: GitCommit, format: string, force: boolean = false) { | |||
if (!force && !config.annotation.author && (!config.annotation.date || config.annotation.date === 'off')) return ''; | |||
if (!config.annotation.author) { | |||
return this.getDate(config, commit, format); | |||
} | |||
if (!config.annotation.date || config.annotation.date === 'off') { | |||
return this.getAuthor(config, commit); | |||
} | |||
return `${this.getAuthor(config, commit)}, ${this.getDate(config, commit, format)}`; | |||
} | |||
static getAuthor(config: IBlameConfig, commit: GitCommit, truncateTo: number = 0, force: boolean = false) { | |||
if (!force && !config.annotation.author) return ''; | |||
const author = commit.isUncommitted ? 'Uncommitted' : commit.author; | |||
if (!truncateTo) return author; | |||
if (author.length > truncateTo) { | |||
return `${author.substring(0, truncateTo - 1)}\u2026`; | |||
} | |||
if (force) return author; // Don't pad when just asking for the value | |||
return author + '\u00a0'.repeat(truncateTo - author.length); | |||
} | |||
static getDate(config: IBlameConfig, commit: GitCommit, format: string, truncate: boolean = false, force: boolean = false) { | |||
if (!force && (!config.annotation.date || config.annotation.date === 'off')) return ''; | |||
const date = config.annotation.date === 'relative' | |||
? moment(commit.date).fromNow() | |||
: moment(commit.date).format(format); | |||
if (!truncate) return date; | |||
const truncateTo = config.annotation.date === 'relative' ? defaultRelativeDateLength : defaultAbsoluteDateLength; | |||
if (date.length > truncateTo) { | |||
return `${date.substring(0, truncateTo - 1)}\u2026`; | |||
} | |||
if (force) return date; // Don't pad when just asking for the value | |||
return date + '\u00a0'.repeat(truncateTo - date.length); | |||
} | |||
static getMessage(config: IBlameConfig, commit: GitCommit, truncateTo: number = 0, force: boolean = false) { | |||
if (!force && !config.annotation.message) return ''; | |||
const message = commit.isUncommitted ? 'Uncommitted change' : commit.message; | |||
if (truncateTo && message.length > truncateTo) { | |||
return `${message.substring(0, truncateTo - 1)}\u2026`; | |||
} | |||
return message; | |||
} | |||
} |
@ -1,302 +0,0 @@ | |||
'use strict'; | |||
import { Iterables } from './system'; | |||
import { DecorationInstanceRenderOptions, DecorationOptions, Disposable, ExtensionContext, Range, TextDocument, TextEditor, TextEditorSelectionChangeEvent, window, workspace } from 'vscode'; | |||
import { BlameAnnotationFormat, BlameAnnotationFormatter, defaultAuthorLength } from './blameAnnotationFormatter'; | |||
import { BlameDecorations } from './blameAnnotationController'; | |||
import { TextDocumentComparer } from './comparers'; | |||
import { BlameAnnotationStyle, IBlameConfig } from './configuration'; | |||
import { ExtensionKey } from './constants'; | |||
import { GitService, GitUri, IGitBlame } from './gitService'; | |||
import { WhitespaceController } from './whitespaceController'; | |||
export class BlameAnnotationProvider extends Disposable { | |||
public document: TextDocument; | |||
private _blame: Promise<IGitBlame>; | |||
private _config: IBlameConfig; | |||
private _disposable: Disposable; | |||
constructor(context: ExtensionContext, private git: GitService, private whitespaceController: WhitespaceController | undefined, public editor: TextEditor, private uri: GitUri) { | |||
super(() => this.dispose()); | |||
this.document = this.editor.document; | |||
this._blame = this.git.getBlameForFile(this.uri); | |||
this._config = workspace.getConfiguration(ExtensionKey).get<IBlameConfig>('blame')!; | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(window.onDidChangeTextEditorSelection(this._onActiveSelectionChanged, this)); | |||
this._disposable = Disposable.from(...subscriptions); | |||
} | |||
async dispose() { | |||
if (this.editor) { | |||
try { | |||
this.editor.setDecorations(BlameDecorations.annotation, []); | |||
BlameDecorations.highlight && this.editor.setDecorations(BlameDecorations.highlight, []); | |||
// I have no idea why the decorators sometimes don't get removed, but if they don't try again with a tiny delay | |||
if (BlameDecorations.highlight !== undefined) { | |||
setTimeout(() => { | |||
if (BlameDecorations.highlight === undefined) return; | |||
this.editor.setDecorations(BlameDecorations.highlight, []); | |||
}, 1); | |||
} | |||
} | |||
catch (ex) { } | |||
} | |||
// HACK: Until https://github.com/Microsoft/vscode/issues/11485 is fixed -- restore whitespace | |||
this.whitespaceController && await this.whitespaceController.restore(); | |||
this._disposable && this._disposable.dispose(); | |||
} | |||
private async _onActiveSelectionChanged(e: TextEditorSelectionChangeEvent) { | |||
if (!TextDocumentComparer.equals(this.document, e.textEditor && e.textEditor.document)) return; | |||
return this.setSelection(e.selections[0].active.line); | |||
} | |||
async supportsBlame(): Promise<boolean> { | |||
const blame = await this._blame; | |||
return !!(blame && blame.lines.length); | |||
} | |||
async provideBlameAnnotation(shaOrLine?: string | number): Promise<boolean> { | |||
let whitespacePromise: Promise<void> | undefined; | |||
// HACK: Until https://github.com/Microsoft/vscode/issues/11485 is fixed -- override whitespace (turn off) | |||
if (this._config.annotation.style !== BlameAnnotationStyle.Trailing) { | |||
whitespacePromise = this.whitespaceController && this.whitespaceController.override(); | |||
} | |||
let blame: IGitBlame; | |||
if (whitespacePromise) { | |||
[blame] = await Promise.all([this._blame, whitespacePromise]); | |||
} | |||
else { | |||
blame = await this._blame; | |||
} | |||
if (!blame || !blame.lines.length) { | |||
this.whitespaceController && await this.whitespaceController.restore(); | |||
return false; | |||
} | |||
let blameDecorationOptions: DecorationOptions[] | undefined; | |||
switch (this._config.annotation.style) { | |||
case BlameAnnotationStyle.Compact: | |||
blameDecorationOptions = this._getCompactGutterDecorations(blame); | |||
break; | |||
case BlameAnnotationStyle.Expanded: | |||
blameDecorationOptions = this._getExpandedGutterDecorations(blame, false); | |||
break; | |||
case BlameAnnotationStyle.Trailing: | |||
blameDecorationOptions = this._getExpandedGutterDecorations(blame, true); | |||
break; | |||
} | |||
if (blameDecorationOptions) { | |||
this.editor.setDecorations(BlameDecorations.annotation, blameDecorationOptions); | |||
} | |||
this._setSelection(blame, shaOrLine); | |||
return true; | |||
} | |||
async setSelection(shaOrLine?: string | number) { | |||
const blame = await this._blame; | |||
if (!blame || !blame.lines.length) return; | |||
return this._setSelection(blame, shaOrLine); | |||
} | |||
private _setSelection(blame: IGitBlame, shaOrLine?: string | number) { | |||
if (!BlameDecorations.highlight) return; | |||
const offset = this.uri.offset; | |||
let sha: string | undefined = undefined; | |||
if (typeof shaOrLine === 'string') { | |||
sha = shaOrLine; | |||
} | |||
else if (typeof shaOrLine === 'number') { | |||
const line = shaOrLine - offset; | |||
if (line >= 0) { | |||
const commitLine = blame.lines[line]; | |||
sha = commitLine && commitLine.sha; | |||
} | |||
} | |||
else { | |||
sha = Iterables.first(blame.commits.values()).sha; | |||
} | |||
if (!sha) { | |||
this.editor.setDecorations(BlameDecorations.highlight, []); | |||
return; | |||
} | |||
const highlightDecorationRanges = blame.lines | |||
.filter(l => l.sha === sha) | |||
.map(l => this.editor.document.validateRange(new Range(l.line + offset, 0, l.line + offset, 1000000))); | |||
this.editor.setDecorations(BlameDecorations.highlight, highlightDecorationRanges); | |||
} | |||
private _getCompactGutterDecorations(blame: IGitBlame): DecorationOptions[] { | |||
const offset = this.uri.offset; | |||
let count = 0; | |||
let lastSha: string; | |||
return blame.lines.map(l => { | |||
const commit = blame.commits.get(l.sha); | |||
if (commit === undefined) throw new Error(`Cannot find sha ${l.sha}`); | |||
let color: string; | |||
if (commit.isUncommitted) { | |||
color = 'rgba(0, 188, 242, 0.6)'; | |||
} | |||
else { | |||
color = l.previousSha ? '#999999' : '#6b6b6b'; | |||
} | |||
let gutter = ''; | |||
if (lastSha !== l.sha) { | |||
count = -1; | |||
} | |||
const isEmptyOrWhitespace = this.document.lineAt(l.line).isEmptyOrWhitespace; | |||
if (!isEmptyOrWhitespace) { | |||
switch (++count) { | |||
case 0: | |||
gutter = commit.shortSha; | |||
break; | |||
case 1: | |||
gutter = `\u2759 ${BlameAnnotationFormatter.getAuthor(this._config, commit, defaultAuthorLength, true)}`; | |||
break; | |||
case 2: | |||
gutter = `\u2759 ${BlameAnnotationFormatter.getDate(this._config, commit, this._config.annotation.dateFormat || 'MM/DD/YYYY', true, true)}`; | |||
break; | |||
default: | |||
gutter = `\u2759`; | |||
break; | |||
} | |||
} | |||
const hoverMessage = BlameAnnotationFormatter.getAnnotationHover(this._config, l, commit); | |||
lastSha = l.sha; | |||
return { | |||
range: this.editor.document.validateRange(new Range(l.line + offset, 0, l.line + offset, 1000000)), | |||
hoverMessage: hoverMessage, | |||
renderOptions: { | |||
before: { | |||
color: color, | |||
contentText: gutter, | |||
width: '11em' | |||
} | |||
} | |||
} as DecorationOptions; | |||
}); | |||
} | |||
private _getExpandedGutterDecorations(blame: IGitBlame, trailing: boolean = false): DecorationOptions[] { | |||
const offset = this.uri.offset; | |||
let width = 0; | |||
if (!trailing) { | |||
if (this._config.annotation.sha) { | |||
width += 5; | |||
} | |||
if (this._config.annotation.date && this._config.annotation.date !== 'off') { | |||
if (width > 0) { | |||
width += 7; | |||
} | |||
else { | |||
width += 6; | |||
} | |||
if (this._config.annotation.date === 'relative') { | |||
width += 2; | |||
} | |||
} | |||
if (this._config.annotation.author) { | |||
if (width > 5 + 6) { | |||
width += 12; | |||
} | |||
else if (width > 0) { | |||
width += 11; | |||
} | |||
else { | |||
width += 10; | |||
} | |||
} | |||
if (this._config.annotation.message) { | |||
if (width > 5 + 6 + 10) { | |||
width += 21; | |||
} | |||
else if (width > 5 + 6) { | |||
width += 21; | |||
} | |||
else if (width > 0) { | |||
width += 21; | |||
} | |||
else { | |||
width += 19; | |||
} | |||
} | |||
} | |||
return blame.lines.map(l => { | |||
const commit = blame.commits.get(l.sha); | |||
if (commit === undefined) throw new Error(`Cannot find sha ${l.sha}`); | |||
let color: string; | |||
if (commit.isUncommitted) { | |||
color = 'rgba(0, 188, 242, 0.6)'; | |||
} | |||
else { | |||
if (trailing) { | |||
color = l.previousSha ? 'rgba(153, 153, 153, 0.5)' : 'rgba(107, 107, 107, 0.5)'; | |||
} | |||
else { | |||
color = l.previousSha ? 'rgb(153, 153, 153)' : 'rgb(107, 107, 107)'; | |||
} | |||
} | |||
const format = trailing ? BlameAnnotationFormat.Unconstrained : BlameAnnotationFormat.Constrained; | |||
const gutter = BlameAnnotationFormatter.getAnnotation(this._config, commit, format); | |||
const hoverMessage = BlameAnnotationFormatter.getAnnotationHover(this._config, l, commit); | |||
let renderOptions: DecorationInstanceRenderOptions; | |||
if (trailing) { | |||
renderOptions = { | |||
after: { | |||
color: color, | |||
contentText: gutter | |||
} | |||
} as DecorationInstanceRenderOptions; | |||
} | |||
else { | |||
renderOptions = { | |||
before: { | |||
color: color, | |||
contentText: gutter, | |||
width: `${width}em` | |||
} | |||
} as DecorationInstanceRenderOptions; | |||
} | |||
return { | |||
range: this.editor.document.validateRange(new Range(l.line + offset, 0, l.line + offset, 1000000)), | |||
hoverMessage: hoverMessage, | |||
renderOptions: renderOptions | |||
} as DecorationOptions; | |||
}); | |||
} | |||
} |
@ -1,28 +0,0 @@ | |||
'use strict'; | |||
import { TextEditor, TextEditorEdit, Uri, window } from 'vscode'; | |||
import { BlameAnnotationController } from '../blameAnnotationController'; | |||
import { Commands, EditorCommand } from './common'; | |||
import { Logger } from '../logger'; | |||
export interface ShowBlameCommandArgs { | |||
sha?: string; | |||
} | |||
export class ShowBlameCommand extends EditorCommand { | |||
constructor(private annotationController: BlameAnnotationController) { | |||
super(Commands.ShowBlame); | |||
} | |||
async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ShowBlameCommandArgs = {}): Promise<any> { | |||
if (editor !== undefined && editor.document !== undefined && editor.document.isDirty) return undefined; | |||
try { | |||
return this.annotationController.showBlameAnnotation(editor, args.sha !== undefined ? args.sha : editor.selection.active.line); | |||
} | |||
catch (ex) { | |||
Logger.error(ex, 'ShowBlameCommand'); | |||
return window.showErrorMessage(`Unable to show blame annotations. See output channel for more details`); | |||
} | |||
} | |||
} |
@ -0,0 +1,35 @@ | |||
'use strict'; | |||
import { TextEditor, TextEditorEdit, Uri, window, workspace } from 'vscode'; | |||
import { AnnotationController } from '../annotations/annotationController'; | |||
import { Commands, EditorCommand } from './common'; | |||
import { ExtensionKey, FileAnnotationType, IConfig } from '../configuration'; | |||
import { Logger } from '../logger'; | |||
export interface ShowFileBlameCommandArgs { | |||
sha?: string; | |||
type?: FileAnnotationType; | |||
} | |||
export class ShowFileBlameCommand extends EditorCommand { | |||
constructor(private annotationController: AnnotationController) { | |||
super(Commands.ShowFileBlame); | |||
} | |||
async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ShowFileBlameCommandArgs = {}): Promise<any> { | |||
if (editor !== undefined && editor.document !== undefined && editor.document.isDirty) return undefined; | |||
try { | |||
if (args.type === undefined) { | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
args.type = cfg.blame.file.annotationType; | |||
} | |||
return this.annotationController.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`); | |||
} | |||
} | |||
} |
@ -0,0 +1,34 @@ | |||
'use strict'; | |||
import { TextEditor, TextEditorEdit, Uri, window, workspace } from 'vscode'; | |||
import { CurrentLineController } from '../currentLineController'; | |||
import { Commands, EditorCommand } from './common'; | |||
import { ExtensionKey, IConfig, LineAnnotationType } from '../configuration'; | |||
import { Logger } from '../logger'; | |||
export interface ShowLineBlameCommandArgs { | |||
type?: LineAnnotationType; | |||
} | |||
export class ShowLineBlameCommand extends EditorCommand { | |||
constructor(private currentLineController: CurrentLineController) { | |||
super(Commands.ShowLineBlame); | |||
} | |||
async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ShowLineBlameCommandArgs = {}): Promise<any> { | |||
if (editor !== undefined && editor.document !== undefined && editor.document.isDirty) return undefined; | |||
try { | |||
if (args.type === undefined) { | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
args.type = cfg.blame.line.annotationType; | |||
} | |||
return this.currentLineController.showAnnotations(editor, args.type); | |||
} | |||
catch (ex) { | |||
Logger.error(ex, 'ShowLineBlameCommand'); | |||
return window.showErrorMessage(`Unable to show line blame annotations. See output channel for more details`); | |||
} | |||
} | |||
} |
@ -1,28 +0,0 @@ | |||
'use strict'; | |||
import { TextEditor, TextEditorEdit, Uri, window } from 'vscode'; | |||
import { BlameAnnotationController } from '../blameAnnotationController'; | |||
import { Commands, EditorCommand } from './common'; | |||
import { Logger } from '../logger'; | |||
export interface ToggleBlameCommandArgs { | |||
sha?: string; | |||
} | |||
export class ToggleBlameCommand extends EditorCommand { | |||
constructor(private annotationController: BlameAnnotationController) { | |||
super(Commands.ToggleBlame); | |||
} | |||
async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ToggleBlameCommandArgs = {}): Promise<any> { | |||
if (editor !== undefined && editor.document !== undefined && editor.document.isDirty) return undefined; | |||
try { | |||
return this.annotationController.toggleBlameAnnotation(editor, args.sha !== undefined ? args.sha : editor.selection.active.line); | |||
} | |||
catch (ex) { | |||
Logger.error(ex, 'ToggleBlameCommand'); | |||
return window.showErrorMessage(`Unable to show blame annotations. See output channel for more details`); | |||
} | |||
} | |||
} |
@ -0,0 +1,35 @@ | |||
'use strict'; | |||
import { TextEditor, TextEditorEdit, Uri, window, workspace } from 'vscode'; | |||
import { AnnotationController } from '../annotations/annotationController'; | |||
import { Commands, EditorCommand } from './common'; | |||
import { ExtensionKey, FileAnnotationType, IConfig } from '../configuration'; | |||
import { Logger } from '../logger'; | |||
export interface ToggleFileBlameCommandArgs { | |||
sha?: string; | |||
type?: FileAnnotationType; | |||
} | |||
export class ToggleFileBlameCommand extends EditorCommand { | |||
constructor(private annotationController: AnnotationController) { | |||
super(Commands.ToggleFileBlame); | |||
} | |||
async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ToggleFileBlameCommandArgs = {}): Promise<any> { | |||
if (editor !== undefined && editor.document !== undefined && editor.document.isDirty) return undefined; | |||
try { | |||
if (args.type === undefined) { | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
args.type = cfg.blame.file.annotationType; | |||
} | |||
return this.annotationController.toggleAnnotations(editor, args.type, args.sha !== undefined ? args.sha : editor.selection.active.line); | |||
} | |||
catch (ex) { | |||
Logger.error(ex, 'ToggleFileBlameCommand'); | |||
return window.showErrorMessage(`Unable to toggle file blame annotations. See output channel for more details`); | |||
} | |||
} | |||
} |
@ -0,0 +1,34 @@ | |||
'use strict'; | |||
import { TextEditor, TextEditorEdit, Uri, window, workspace } from 'vscode'; | |||
import { CurrentLineController } from '../currentLineController'; | |||
import { Commands, EditorCommand } from './common'; | |||
import { ExtensionKey, IConfig, LineAnnotationType } from '../configuration'; | |||
import { Logger } from '../logger'; | |||
export interface ToggleLineBlameCommandArgs { | |||
type?: LineAnnotationType; | |||
} | |||
export class ToggleLineBlameCommand extends EditorCommand { | |||
constructor(private currentLineController: CurrentLineController) { | |||
super(Commands.ToggleLineBlame); | |||
} | |||
async execute(editor: TextEditor, edit: TextEditorEdit, uri?: Uri, args: ToggleLineBlameCommandArgs = {}): Promise<any> { | |||
if (editor !== undefined && editor.document !== undefined && editor.document.isDirty) return undefined; | |||
try { | |||
if (args.type === undefined) { | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
args.type = cfg.blame.line.annotationType; | |||
} | |||
return this.currentLineController.toggleAnnotations(editor, args.type); | |||
} | |||
catch (ex) { | |||
Logger.error(ex, 'ToggleLineBlameCommand'); | |||
return window.showErrorMessage(`Unable to toggle line blame annotations. See output channel for more details`); | |||
} | |||
} | |||
} |
@ -0,0 +1,437 @@ | |||
'use strict'; | |||
import { Functions, Objects } from './system'; | |||
import { DecorationOptions, DecorationRenderOptions, Disposable, ExtensionContext, Range, StatusBarAlignment, StatusBarItem, TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent, window, workspace } from 'vscode'; | |||
import { AnnotationController } from './annotations/annotationController'; | |||
import { Annotations, endOfLineIndex } from './annotations/annotations'; | |||
import { Commands } from './commands'; | |||
import { TextEditorComparer } from './comparers'; | |||
import { FileAnnotationType, IConfig, LineAnnotationType, StatusBarCommand } from './configuration'; | |||
import { DocumentSchemes, ExtensionKey } from './constants'; | |||
import { BlameabilityChangeEvent, CommitFormatter, GitCommit, GitContextTracker, GitService, GitUri, IGitCommitLine } from './gitService'; | |||
const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({ | |||
after: { | |||
margin: '0 0 0 4em' | |||
} | |||
} as DecorationRenderOptions); | |||
export class CurrentLineController extends Disposable { | |||
private _activeEditorLineDisposable: Disposable | undefined; | |||
private _blameable: boolean; | |||
private _config: IConfig; | |||
private _currentLine: number = -1; | |||
private _disposable: Disposable; | |||
private _editor: TextEditor | undefined; | |||
private _statusBarItem: StatusBarItem | undefined; | |||
private _updateBlameDebounced: (line: number, editor: TextEditor) => Promise<void>; | |||
private _uri: GitUri; | |||
constructor(context: ExtensionContext, private git: GitService, private gitContextTracker: GitContextTracker, private annotationController: AnnotationController) { | |||
super(() => this.dispose()); | |||
this._updateBlameDebounced = Functions.debounce(this._updateBlame, 250); | |||
this._onConfigurationChanged(); | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(workspace.onDidChangeConfiguration(this._onConfigurationChanged, this)); | |||
subscriptions.push(git.onDidChangeGitCache(this._onGitCacheChanged, this)); | |||
subscriptions.push(annotationController.onDidToggleAnnotations(this._onAnnotationsToggled, this)); | |||
this._disposable = Disposable.from(...subscriptions); | |||
} | |||
dispose() { | |||
this._editor && this._editor.setDecorations(annotationDecoration, []); | |||
this._activeEditorLineDisposable && this._activeEditorLineDisposable.dispose(); | |||
this._statusBarItem && this._statusBarItem.dispose(); | |||
this._disposable && this._disposable.dispose(); | |||
} | |||
private _onConfigurationChanged() { | |||
const cfg = workspace.getConfiguration().get<IConfig>(ExtensionKey)!; | |||
let changed = false; | |||
if (!Objects.areEquivalent(cfg.blame.line, this._config && this._config.blame.line) || | |||
!Objects.areEquivalent(cfg.annotations.line.trailing, this._config && this._config.annotations.line.trailing) || | |||
!Objects.areEquivalent(cfg.annotations.line.hover, this._config && this._config.annotations.line.hover) || | |||
!Objects.areEquivalent(cfg.theme.annotations.line.trailing, this._config && this._config.theme.annotations.line.trailing)) { | |||
changed = true; | |||
if (this._editor) { | |||
this._editor.setDecorations(annotationDecoration, []); | |||
} | |||
} | |||
if (!Objects.areEquivalent(cfg.statusBar, this._config && this._config.statusBar)) { | |||
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 (!cfg.statusBar.enabled && this._statusBarItem) { | |||
this._statusBarItem.dispose(); | |||
this._statusBarItem = undefined; | |||
} | |||
} | |||
this._config = cfg; | |||
if (!changed) return; | |||
const trackCurrentLine = cfg.statusBar.enabled || cfg.blame.line.enabled; | |||
if (trackCurrentLine && !this._activeEditorLineDisposable) { | |||
const subscriptions: Disposable[] = []; | |||
subscriptions.push(window.onDidChangeActiveTextEditor(this._onActiveTextEditorChanged, this)); | |||
subscriptions.push(window.onDidChangeTextEditorSelection(this._onTextEditorSelectionChanged, this)); | |||
subscriptions.push(this.gitContextTracker.onDidBlameabilityChange(this._onBlameabilityChanged, this)); | |||
this._activeEditorLineDisposable = Disposable.from(...subscriptions); | |||
} | |||
else if (!trackCurrentLine && this._activeEditorLineDisposable) { | |||
this._activeEditorLineDisposable.dispose(); | |||
this._activeEditorLineDisposable = undefined; | |||
} | |||
this._onActiveTextEditorChanged(window.activeTextEditor); | |||
} | |||
private isEditorBlameable(editor: TextEditor | undefined): boolean { | |||
if (editor === undefined || editor.document === undefined) return false; | |||
if (!this.git.isTrackable(editor.document.uri)) return false; | |||
if (editor.document.isUntitled && editor.document.uri.scheme === DocumentSchemes.File) return false; | |||
return this.git.isEditorBlameable(editor); | |||
} | |||
private async _onActiveTextEditorChanged(editor: TextEditor | undefined) { | |||
this._currentLine = -1; | |||
const previousEditor = this._editor; | |||
previousEditor && previousEditor.setDecorations(annotationDecoration, []); | |||
if (editor === undefined || !this.isEditorBlameable(editor)) { | |||
this.clear(editor); | |||
this._editor = undefined; | |||
return; | |||
} | |||
this._blameable = editor !== undefined && editor.document !== undefined && !editor.document.isDirty; | |||
this._editor = editor; | |||
this._uri = await GitUri.fromUri(editor.document.uri, this.git); | |||
const maxLines = this._config.advanced.caching.maxLines; | |||
// If caching is on and the file is small enough -- kick off a blame for the whole file | |||
if (this._config.advanced.caching.enabled && (maxLines <= 0 || editor.document.lineCount <= maxLines)) { | |||
this.git.getBlameForFile(this._uri); | |||
} | |||
this._updateBlameDebounced(editor.selection.active.line, editor); | |||
} | |||
private _onBlameabilityChanged(e: BlameabilityChangeEvent) { | |||
this._blameable = e.blameable; | |||
if (!e.blameable || !this._editor) { | |||
this.clear(e.editor); | |||
return; | |||
} | |||
// Make sure this is for the editor we are tracking | |||
if (!TextEditorComparer.equals(this._editor, e.editor)) return; | |||
this._updateBlameDebounced(this._editor.selection.active.line, this._editor); | |||
} | |||
private _onAnnotationsToggled() { | |||
this._onActiveTextEditorChanged(window.activeTextEditor); | |||
} | |||
private _onGitCacheChanged() { | |||
this._onActiveTextEditorChanged(window.activeTextEditor); | |||
} | |||
private async _onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent): Promise<void> { | |||
// Make sure this is for the editor we are tracking | |||
if (!this._blameable || !TextEditorComparer.equals(this._editor, e.textEditor)) return; | |||
const line = e.selections[0].active.line; | |||
if (line === this._currentLine) return; | |||
this._currentLine = line; | |||
if (!this._uri && e.textEditor) { | |||
this._uri = await GitUri.fromUri(e.textEditor.document.uri, this.git); | |||
} | |||
this._updateBlameDebounced(line, e.textEditor); | |||
} | |||
private async _updateBlame(line: number, editor: TextEditor) { | |||
line = line - this._uri.offset; | |||
let commit: GitCommit | undefined = undefined; | |||
let commitLine: IGitCommitLine | undefined = undefined; | |||
// Since blame information isn't valid when there are unsaved changes -- don't show any status | |||
if (this._blameable && line >= 0) { | |||
const blameLine = await this.git.getBlameForLine(this._uri, line); | |||
commitLine = blameLine === undefined ? undefined : blameLine.line; | |||
commit = blameLine === undefined ? undefined : blameLine.commit; | |||
} | |||
if (commit !== undefined && commitLine !== undefined) { | |||
this.show(commit, commitLine, editor); | |||
} | |||
else { | |||
this.clear(editor); | |||
} | |||
} | |||
async clear(editor: TextEditor | undefined, previousEditor?: TextEditor) { | |||
this._clearAnnotations(editor, previousEditor); | |||
this._statusBarItem && this._statusBarItem.hide(); | |||
} | |||
private async _clearAnnotations(editor: TextEditor | undefined, previousEditor?: TextEditor) { | |||
editor && editor.setDecorations(annotationDecoration, []); | |||
// I have no idea why the decorators sometimes don't get removed, but if they don't try again with a tiny delay | |||
if (editor !== undefined) { | |||
await Functions.wait(1); | |||
editor.setDecorations(annotationDecoration, []); | |||
} | |||
} | |||
async show(commit: GitCommit, blameLine: IGitCommitLine, editor: TextEditor) { | |||
// I have no idea why I need this protection -- but it happens | |||
if (editor.document === undefined) return; | |||
this._updateStatusBar(commit); | |||
await this._updateAnnotations(commit, blameLine, editor); | |||
} | |||
async showAnnotations(editor: TextEditor, type: LineAnnotationType) { | |||
if (editor === undefined) return; | |||
const cfg = this._config.blame.line; | |||
if (!cfg.enabled || cfg.annotationType !== type) { | |||
cfg.enabled = true; | |||
cfg.annotationType = type; | |||
await this._clearAnnotations(editor); | |||
await this._updateBlame(editor.selection.active.line, editor); | |||
} | |||
} | |||
async toggleAnnotations(editor: TextEditor, type: LineAnnotationType) { | |||
if (editor === undefined) return; | |||
const cfg = this._config.blame.line; | |||
cfg.enabled = !cfg.enabled; | |||
cfg.annotationType = type; | |||
await this._clearAnnotations(editor); | |||
await this._updateBlame(editor.selection.active.line, editor); | |||
} | |||
private async _updateAnnotations(commit: GitCommit, blameLine: IGitCommitLine, editor: TextEditor) { | |||
const cfg = this._config.blame.line; | |||
if (!cfg.enabled) return; | |||
const line = blameLine.line + this._uri.offset; | |||
const decorationOptions: DecorationOptions[] = []; | |||
let showChanges = false; | |||
let showChangesStartIndex = 0; | |||
let showChangesInStartingWhitespace = false; | |||
let showDetails = false; | |||
let showDetailsStartIndex = 0; | |||
let showDetailsInStartingWhitespace = false; | |||
switch (cfg.annotationType) { | |||
case LineAnnotationType.Trailing: { | |||
const cfgAnnotations = this._config.annotations.line.trailing; | |||
showChanges = cfgAnnotations.hover.changes; | |||
showDetails = cfgAnnotations.hover.details; | |||
if (cfgAnnotations.hover.wholeLine) { | |||
showChangesStartIndex = 0; | |||
showChangesInStartingWhitespace = false; | |||
showDetailsStartIndex = 0; | |||
showDetailsInStartingWhitespace = false; | |||
} | |||
else { | |||
showChangesStartIndex = endOfLineIndex; | |||
showChangesInStartingWhitespace = true; | |||
showDetailsStartIndex = endOfLineIndex; | |||
showDetailsInStartingWhitespace = true; | |||
} | |||
const decoration = Annotations.trailing(commit, cfgAnnotations.format, cfgAnnotations.dateFormat, this._config.theme); | |||
decoration.range = editor.document.validateRange(new Range(line, endOfLineIndex, line, endOfLineIndex)); | |||
decorationOptions.push(decoration); | |||
break; | |||
} | |||
case LineAnnotationType.Hover: { | |||
const cfgAnnotations = this._config.annotations.line.hover; | |||
showChanges = cfgAnnotations.changes; | |||
showChangesStartIndex = 0; | |||
showChangesInStartingWhitespace = false; | |||
showDetails = cfgAnnotations.details; | |||
showDetailsStartIndex = 0; | |||
showDetailsInStartingWhitespace = false; | |||
break; | |||
} | |||
} | |||
if (showDetails || showChanges) { | |||
const annotationType = this.annotationController.getAnnotationType(editor); | |||
const firstNonWhitespace = editor.document.lineAt(line).firstNonWhitespaceCharacterIndex; | |||
switch (annotationType) { | |||
case FileAnnotationType.Gutter: { | |||
const cfgHover = this._config.annotations.file.gutter.hover; | |||
if (cfgHover.details) { | |||
showDetailsInStartingWhitespace = false; | |||
if (cfgHover.wholeLine) { | |||
// Avoid double annotations if we are showing the whole-file hover blame annotations | |||
showDetails = false; | |||
} | |||
else { | |||
if (showDetailsStartIndex === 0) { | |||
showDetailsStartIndex = firstNonWhitespace === 0 ? 1 : firstNonWhitespace; | |||
} | |||
if (showChangesStartIndex === 0) { | |||
showChangesInStartingWhitespace = true; | |||
showChangesStartIndex = firstNonWhitespace === 0 ? 1 : firstNonWhitespace; | |||
} | |||
} | |||
} | |||
break; | |||
} | |||
case FileAnnotationType.Hover: { | |||
const cfgHover = this._config.annotations.file.hover; | |||
showDetailsInStartingWhitespace = false; | |||
if (cfgHover.wholeLine) { | |||
// Avoid double annotations if we are showing the whole-file hover blame annotations | |||
showDetails = false; | |||
showChangesStartIndex = 0; | |||
} | |||
else { | |||
if (showDetailsStartIndex === 0) { | |||
showDetailsStartIndex = firstNonWhitespace === 0 ? 1 : firstNonWhitespace; | |||
} | |||
if (showChangesStartIndex === 0) { | |||
showChangesInStartingWhitespace = true; | |||
showChangesStartIndex = firstNonWhitespace === 0 ? 1 : firstNonWhitespace; | |||
} | |||
} | |||
break; | |||
} | |||
} | |||
if (showDetails) { | |||
// Get the full commit message -- since blame only returns the summary | |||
let logCommit: GitCommit | undefined = undefined; | |||
if (!commit.isUncommitted) { | |||
logCommit = await this.git.getLogCommit(this._uri.repoPath, this._uri.fsPath, commit.sha); | |||
} | |||
// I have no idea why I need this protection -- but it happens | |||
if (editor.document === undefined) return; | |||
const decoration = Annotations.detailsHover(logCommit || commit); | |||
decoration.range = editor.document.validateRange(new Range(line, showDetailsStartIndex, line, endOfLineIndex)); | |||
decorationOptions.push(decoration); | |||
if (showDetailsInStartingWhitespace && showDetailsStartIndex !== 0) { | |||
decorationOptions.push(Annotations.withRange(decoration, 0, firstNonWhitespace)); | |||
} | |||
} | |||
if (showChanges) { | |||
const decoration = await Annotations.changesHover(commit, line, this._uri, this.git); | |||
// I have no idea why I need this protection -- but it happens | |||
if (editor.document === undefined) return; | |||
decoration.range = editor.document.validateRange(new Range(line, showChangesStartIndex, line, endOfLineIndex)); | |||
decorationOptions.push(decoration); | |||
if (showChangesInStartingWhitespace && showChangesStartIndex !== 0) { | |||
decorationOptions.push(Annotations.withRange(decoration, 0, firstNonWhitespace)); | |||
} | |||
} | |||
} | |||
if (decorationOptions.length) { | |||
editor.setDecorations(annotationDecoration, decorationOptions); | |||
} | |||
} | |||
private _updateStatusBar(commit: GitCommit) { | |||
const cfg = this._config.statusBar; | |||
if (!cfg.enabled || this._statusBarItem === undefined) return; | |||
this._statusBarItem.text = `$(git-commit) ${CommitFormatter.fromTemplate(cfg.format, commit, cfg.dateFormat)}`; | |||
switch (cfg.command) { | |||
case StatusBarCommand.BlameAnnotate: | |||
this._statusBarItem.tooltip = 'Toggle Blame Annotations'; | |||
break; | |||
case StatusBarCommand.ShowBlameHistory: | |||
this._statusBarItem.tooltip = 'Open Blame History Explorer'; | |||
break; | |||
case StatusBarCommand.ShowFileHistory: | |||
this._statusBarItem.tooltip = 'Open File History Explorer'; | |||
break; | |||
case StatusBarCommand.DiffWithPrevious: | |||
this._statusBarItem.command = Commands.DiffLineWithPrevious; | |||
this._statusBarItem.tooltip = 'Compare Line Commit with Previous'; | |||
break; | |||
case StatusBarCommand.DiffWithWorking: | |||
this._statusBarItem.command = Commands.DiffLineWithWorking; | |||
this._statusBarItem.tooltip = 'Compare Line Commit with Working Tree'; | |||
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(); | |||
} | |||
} |
@ -0,0 +1,160 @@ | |||
'use strict'; | |||
import { Strings } from '../../system'; | |||
import { GitCommit } from '../models/commit'; | |||
import { IGitDiffLine } from '../models/diff'; | |||
import * as moment from 'moment'; | |||
export interface ICommitFormatOptions { | |||
dateFormat?: string | null; | |||
tokenOptions?: { | |||
ago?: Strings.ITokenOptions; | |||
author?: Strings.ITokenOptions; | |||
authorAgo?: Strings.ITokenOptions; | |||
date?: Strings.ITokenOptions; | |||
message?: Strings.ITokenOptions; | |||
}; | |||
} | |||
export class CommitFormatter { | |||
private _options: ICommitFormatOptions; | |||
constructor(private commit: GitCommit, options?: ICommitFormatOptions) { | |||
options = options || {}; | |||
if (options.tokenOptions == null) { | |||
options.tokenOptions = {}; | |||
} | |||
if (options.dateFormat == null) { | |||
options.dateFormat = 'MMMM Do, YYYY h:MMa'; | |||
} | |||
this._options = options; | |||
} | |||
get ago() { | |||
const ago = moment(this.commit.date).fromNow(); | |||
return this._padOrTruncate(ago, this._options.tokenOptions!.ago); | |||
} | |||
get author() { | |||
const author = this.commit.author; | |||
return this._padOrTruncate(author, this._options.tokenOptions!.author); | |||
} | |||
get authorAgo() { | |||
const authorAgo = `${this.commit.author}, ${moment(this.commit.date).fromNow()}`; | |||
return this._padOrTruncate(authorAgo, this._options.tokenOptions!.authorAgo); | |||
} | |||
get date() { | |||
const date = moment(this.commit.date).format(this._options.dateFormat!); | |||
return this._padOrTruncate(date, this._options.tokenOptions!.date); | |||
} | |||
get id() { | |||
return this.commit.shortSha; | |||
} | |||
get message() { | |||
const message = this.commit.isUncommitted ? 'Uncommitted change' : this.commit.message; | |||
return this._padOrTruncate(message, this._options.tokenOptions!.message); | |||
} | |||
get sha() { | |||
return this.id; | |||
} | |||
private collapsableWhitespace: number = 0; | |||
private _padOrTruncate(s: string, options: Strings.ITokenOptions | undefined) { | |||
// NOTE: the collapsable whitespace logic relies on the javascript template evaluation to be left to right | |||
if (options === undefined) { | |||
options = { | |||
truncateTo: undefined, | |||
padDirection: 'left', | |||
collapseWhitespace: false | |||
}; | |||
} | |||
let max = options.truncateTo; | |||
if (max === undefined) { | |||
if (this.collapsableWhitespace === 0) return s; | |||
// If we have left over whitespace make sure it gets re-added | |||
const diff = this.collapsableWhitespace - s.length; | |||
this.collapsableWhitespace = 0; | |||
if (diff <= 0) return s; | |||
if (options.truncateTo === undefined) return s; | |||
return Strings.padLeft(s, diff); | |||
} | |||
max += this.collapsableWhitespace; | |||
this.collapsableWhitespace = 0; | |||
const diff = max - s.length; | |||
if (diff > 0) { | |||
if (options.collapseWhitespace) { | |||
this.collapsableWhitespace = diff; | |||
} | |||
if (options.padDirection === 'left') return Strings.padLeft(s, max); | |||
if (options.collapseWhitespace) { | |||
max -= diff; | |||
} | |||
return Strings.padRight(s, max); | |||
} | |||
if (diff < 0) return Strings.truncate(s, max); | |||
return s; | |||
} | |||
static fromTemplate(template: string, commit: GitCommit, dateFormat: string | null): string; | |||
static fromTemplate(template: string, commit: GitCommit, options?: ICommitFormatOptions): string; | |||
static fromTemplate(template: string, commit: GitCommit, dateFormatOrOptions?: string | null | ICommitFormatOptions): string; | |||
static fromTemplate(template: string, commit: GitCommit, dateFormatOrOptions?: string | null | ICommitFormatOptions): string { | |||
let options: ICommitFormatOptions | undefined = undefined; | |||
if (dateFormatOrOptions == null || typeof dateFormatOrOptions === 'string') { | |||
const tokenOptions = Strings.getTokensFromTemplate(template) | |||
.reduce((map, token) => { | |||
map[token.key] = token.options; | |||
return map; | |||
}, {} as { [token: string]: ICommitFormatOptions }); | |||
options = { | |||
dateFormat: dateFormatOrOptions, | |||
tokenOptions: tokenOptions | |||
}; | |||
} | |||
else { | |||
options = dateFormatOrOptions; | |||
} | |||
return Strings.interpolateLazy(template, new CommitFormatter(commit, options)); | |||
} | |||
static toHoverAnnotation(commit: GitCommit, dateFormat: string = 'MMMM Do, YYYY h:MMa'): string | string[] { | |||
const message = commit.isUncommitted ? '' : `\n\n> ${commit.message.replace(/\n/g, '\n>\n> ')}`; | |||
return `\`${commit.shortSha}\` __${commit.author}__, ${moment(commit.date).fromNow()} _(${moment(commit.date).format(dateFormat)})_${message}`; | |||
} | |||
static toHoverDiff(commit: GitCommit, previous: IGitDiffLine | undefined, current: IGitDiffLine | undefined): string | undefined { | |||
if (previous === undefined && current === undefined) return undefined; | |||
const codeDiff = this._getCodeDiff(previous, current); | |||
return commit.isUncommitted | |||
? `\`Changes\` \u2014 _uncommitted_\n${codeDiff}` | |||
: `\`Changes\` \u2014 \`${commit.previousShortSha}\` \u2194 \`${commit.shortSha}\`\n${codeDiff}`; | |||
} | |||
private static _getCodeDiff(previous: IGitDiffLine | undefined, current: IGitDiffLine | undefined): string { | |||
return `\`\`\` | |||
- ${previous === undefined ? '' : previous.line.trim()} | |||
+ ${current === undefined ? '' : current.line.trim()} | |||
\`\`\``; | |||
} | |||
} |
@ -1,8 +1,84 @@ | |||
'use strict'; | |||
import { Objects } from './object'; | |||
const _escapeRegExp = require('lodash.escaperegexp'); | |||
export namespace Strings { | |||
export function escapeRegExp(s: string): string { | |||
return _escapeRegExp(s); | |||
} | |||
const TokenRegex = /\$\{([^|]*?)(?:\|(\d+)(\-|\?)?)?\}/g; | |||
const TokenSanitizeRegex = /\$\{(\w*?)(?:\W|\d)*?\}/g; | |||
export interface ITokenOptions { | |||
padDirection: 'left' | 'right'; | |||
truncateTo: number | undefined; | |||
collapseWhitespace: boolean; | |||
} | |||
export function getTokensFromTemplate(template: string) { | |||
const tokens: { key: string, options: ITokenOptions }[] = []; | |||
let match = TokenRegex.exec(template); | |||
while (match != null) { | |||
const truncateTo = match[2]; | |||
const option = match[3]; | |||
tokens.push({ | |||
key: match[1], | |||
options: { | |||
truncateTo: truncateTo == null ? undefined : parseInt(truncateTo, 10), | |||
padDirection: option === '-' ? 'left' : 'right', | |||
collapseWhitespace: option === '?' | |||
} | |||
}); | |||
match = TokenRegex.exec(template); | |||
} | |||
return tokens; | |||
} | |||
export function interpolate(template: string, tokens: { [key: string]: any }): string { | |||
return new Function(...Object.keys(tokens), `return \`${template}\`;`)(...Objects.values(tokens)); | |||
} | |||
export function interpolateLazy(template: string, context: object): string { | |||
template = template.replace(TokenSanitizeRegex, '$${c.$1}'); | |||
return new Function('c', `return \`${template}\`;`)(context); | |||
} | |||
export function padLeft(s: string, padTo: number, padding: string = '\u00a0') { | |||
const diff = padTo - s.length; | |||
return (diff <= 0) ? s : '\u00a0'.repeat(diff) + s; | |||
} | |||
export function padLeftOrTruncate(s: string, max: number, padding?: string) { | |||
if (s.length < max) return padLeft(s, max, padding); | |||
if (s.length > max) return truncate(s, max); | |||
return s; | |||
} | |||
export function padRight(s: string, padTo: number, padding: string = '\u00a0') { | |||
const diff = padTo - s.length; | |||
return (diff <= 0) ? s : s + '\u00a0'.repeat(diff); | |||
} | |||
export function padOrTruncate(s: string, max: number, padding?: string) { | |||
const left = max < 0; | |||
max = Math.abs(max); | |||
if (s.length < max) return left ? padLeft(s, max, padding) : padRight(s, max, padding); | |||
if (s.length > max) return truncate(s, max); | |||
return s; | |||
} | |||
export function padRightOrTruncate(s: string, max: number, padding?: string) { | |||
if (s.length < max) return padRight(s, max, padding); | |||
if (s.length > max) return truncate(s, max); | |||
return s; | |||
} | |||
export function truncate(s: string, truncateTo?: number) { | |||
if (!s || truncateTo === undefined || s.length <= truncateTo) return s; | |||
return `${s.substring(0, truncateTo - 1)}\u2026`; | |||
} | |||
} |