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"?> | <?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"> | <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> | <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> | </g> | ||||
</svg> | </svg> |
@ -1,6 +1,6 @@ | |||||
<?xml version="1.0" encoding="utf-8"?> | <?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"> | <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> | <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> | </g> | ||||
</svg> | </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'; | 'use strict'; | ||||
import { Objects } from './object'; | |||||
const _escapeRegExp = require('lodash.escaperegexp'); | const _escapeRegExp = require('lodash.escaperegexp'); | ||||
export namespace Strings { | export namespace Strings { | ||||
export function escapeRegExp(s: string): string { | export function escapeRegExp(s: string): string { | ||||
return _escapeRegExp(s); | 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`; | |||||
} | |||||
} | } |