diff --git a/.vscode/settings.json b/.vscode/settings.json index 97e5ddb..42c684f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,11 +2,13 @@ { "files.exclude": { "out": false, // set this to true to hide the "out" folder with the compiled JS files - "node_modules": false + "node_modules": true, + "typings": true }, "search.exclude": { "out": true, // set this to false to include "out" folder in search results - "node_modules": true + "node_modules": true, + "typings": true }, "typescript.tsdk": "./node_modules/typescript/lib" // we want to use the TS server from our node_modules folder to control its version } \ No newline at end of file diff --git a/src/commands.ts b/src/commands.ts index d8b8740..de4f83f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,8 +1,10 @@ 'use strict' -import {commands, Disposable, Position, Range, Uri, window} from 'vscode'; +import {commands, DecorationOptions, Disposable, OverviewRulerLane, Position, Range, TextEditorDecorationType, Uri, window} from 'vscode'; import {Commands, VsCodeCommands} from './constants'; import GitProvider from './gitProvider'; +import GitBlameController from './gitBlameController'; import {basename} from 'path'; +import * as moment from 'moment'; abstract class Command extends Disposable { private _subscriptions: Disposable; @@ -14,36 +16,233 @@ abstract class Command extends Disposable { dispose() { this._subscriptions && this._subscriptions.dispose(); - super.dispose(); } abstract execute(...args): any; } export class BlameCommand extends Command { - constructor(private git: GitProvider) { + constructor(private git: GitProvider, private blameController: GitBlameController) { super(Commands.ShowBlameHistory); } - execute(uri?: Uri, range?: Range, position?: Position) { - // If the command is executed manually -- treat it as a click on the root lens (i.e. show blame for the whole file) - if (!uri) { - const doc = window.activeTextEditor && window.activeTextEditor.document; - if (doc) { - uri = doc.uri; - range = doc.validateRange(new Range(0, 0, 1000000, 1000000)); - position = doc.validateRange(new Range(0, 0, 0, 1000000)).start; - } - - if (!uri) return; + execute(uri?: Uri, range?: Range, sha?: string) { + const editor = window.activeTextEditor; + if (!editor) return; + + if (!range) { + range = editor.document.validateRange(new Range(0, 0, 1000000, 1000000)); } - return this.git.getBlameLocations(uri.path, range).then(locations => { - return commands.executeCommand(VsCodeCommands.ShowReferences, uri, position, locations); - }); + if (sha) { + return this.blameController.toggleBlame(editor, sha); + } + + const activeLine = editor.selection.active.line; + return this.git.getBlameForLine(editor.document.fileName, activeLine) + .then(blame => this.blameController.toggleBlame(editor, blame.commit.sha)); } } +// export class BlameCommand extends Command { +// // static Colors: Array> = [ [255, 152, 0], [255, 87, 34], [121, 85, 72], [158, 158, 158], [96, 125, 139], [244, 67, 54], [233, 30, 99], [156, 39, 176], [103, 58, 183] ]; +// // private _decorations: TextEditorDecorationType[] = []; + +// constructor(private git: GitProvider, private blameDecoration: TextEditorDecorationType, private highlightDecoration: TextEditorDecorationType) { +// super(Commands.ShowBlameHistory); + +// // BlameCommand.Colors.forEach(c => { +// // this._decorations.push(window.createTextEditorDecorationType({ +// // dark: { +// // backgroundColor: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.15)`, +// // //gutterIconPath: context.asAbsolutePath('images/blame-dark.png'), +// // overviewRulerColor: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.75)`, +// // }, +// // //light: { +// // //backgroundColor: 'rgba(0, 0, 0, 0.15)', +// // //gutterIconPath: context.asAbsolutePath('images/blame-light.png'), +// // //overviewRulerColor: c //'rgba(0, 0, 0, 0.75)', +// // //}, +// // // before: { +// // // margin: '0 1em 0 0' +// // // }, +// // // after: { +// // // margin: '0 0 0 2em' +// // // }, +// // //gutterIconSize: 'contain', +// // overviewRulerLane: OverviewRulerLane.Right, +// // //isWholeLine: true +// // })); +// // }); +// } + +// execute(uri?: Uri, range?: Range, position?: Position) { +// const editor = window.activeTextEditor; +// if (!editor) { +// return; +// } + +// editor.setDecorations(this.blameDecoration, []); +// editor.setDecorations(this.highlightDecoration, []); + +// const highlightDecorationRanges: Array = []; +// const blameDecorationOptions: Array = []; + +// this.git.getBlameForRange(uri.path, range).then(blame => { +// if (!blame.lines.length) return; + +// const commits = Array.from(blame.commits.values()); +// const recentCommit = commits.sort((a, b) => b.date.getTime() - a.date.getTime())[0]; + +// return this.git.getCommitMessages(uri.path) +// .then(msgs => { +// commits.forEach(c => { +// c.message = msgs.get(c.sha.substring(0, c.sha.length - 1)); +// }); + +// blame.lines.forEach(l => { +// if (l.sha === recentCommit.sha) { +// highlightDecorationRanges.push(editor.document.validateRange(new Range(l.line, 0, l.line, 1000000))); +// } + +// const c = blame.commits.get(l.sha); +// blameDecorationOptions.push({ +// range: editor.document.validateRange(new Range(l.line, 0, l.line, 0)), +// hoverMessage: `${c.sha}: ${c.message}\n${c.author}, ${moment(c.date).format('MMMM Do, YYYY hh:MM a')}`, +// renderOptions: { +// // dark: { +// // backgroundColor: `rgba(255, 255, 255, ${alphas.get(l.sha)})` +// // }, +// before: { +// //border: '1px solid gray', +// //color: 'rgb(128, 128, 128)', +// contentText: `${l.sha}`, +// // margin: '0 1em 0 0', +// // width: '5em' +// } +// // after: { +// // contentText: `${c.author}, ${moment(c.date).format('MMMM Do, YYYY hh:MM a')}`, +// // //color: 'rbg(128, 128, 128)', +// // margin: '0 0 0 2em' +// // } +// } +// }); +// }); +// }); + +// // Array.from(blame.commits.values()).forEach((c, i) => { +// // if (i == 0) { +// // highlightDecorationRanges = blame.lines +// // .filter(l => l.sha === c.sha) +// // .map(l => editor.document.validateRange(new Range(l.line, 0, l.line, 1000000))); +// // } + +// // blameDecorationOptions.push(blame.lines +// // .filter(l => l.sha === c.sha) +// // .map(l => { +// // return { +// // range: editor.document.validateRange(new Range(l.line, 0, l.line, 6)), +// // hoverMessage: `${c.author}\n${moment(c.date).format('MMMM Do, YYYY hh:MM a')}\n${l.sha}`, +// // renderOptions: { +// // // dark: { +// // // backgroundColor: `rgba(255, 255, 255, ${alphas.get(l.sha)})` +// // // }, +// // before: { +// // //border: '1px solid gray', +// // //color: 'rgb(128, 128, 128)', +// // contentText: `${l.sha}`, +// // // margin: '0 1em 0 0', +// // // width: '5em' +// // } +// // // after: { +// // // contentText: `${c.author}, ${moment(c.date).format('MMMM Do, YYYY hh:MM a')}`, +// // // //color: 'rbg(128, 128, 128)', +// // // margin: '0 0 0 2em' +// // // } +// // } +// // }; +// // })); +// // }); +// }) +// .then(() => { +// editor.setDecorations(this.blameDecoration, blameDecorationOptions); +// editor.setDecorations(this.highlightDecoration, highlightDecorationRanges); +// }); + +// // this._decorations.forEach(d => editor.setDecorations(d, [])); +// // this.git.getBlameForRange(uri.path, range).then(blame => { +// // if (!blame.lines.length) return; + +// // Array.from(blame.commits.values()).forEach((c, i) => { +// // editor.setDecorations(this._decorations[i], blame.lines.filter(l => l.sha === c.sha).map(l => { +// // const commit = c; //blame.commits.get(l.sha); +// // return { +// // range: editor.document.validateRange(new Range(l.line, 0, l.line, 1000000)), +// // hoverMessage: `${commit.author}\n${moment(commit.date).format('MMMM Do, YYYY hh:MM a')}\n${l.sha}`, +// // renderOptions: { +// // // dark: { +// // // backgroundColor: `rgba(255, 255, 255, ${alphas.get(l.sha)})` +// // // }, +// // before: { +// // color: 'rgb(128, 128, 128)', +// // contentText: `${l.sha}`, +// // //border: '1px solid gray', +// // width: '5em', +// // margin: '0 1em 0 0' +// // }, +// // after: { +// // contentText: `${commit.author}, ${moment(commit.date).format('MMMM Do, YYYY hh:MM a')}`, +// // //color: 'rbg(128, 128, 128)', +// // margin: '0 0 0 2em' +// // } +// // } +// // }; +// // })); +// // }); + +// // //this.git.getCommitMessage(data.sha).then(msg => { +// // // editor.setDecorations(this._blameDecoration, blame.lines.map(l => { +// // // const commit = blame.commits.get(l.sha); +// // // return { +// // // range: editor.document.validateRange(new Range(l.line, 0, l.line, 1000000)), +// // // hoverMessage: `${commit.author}\n${moment(commit.date).format('MMMM Do, YYYY hh:MM a')}\n${l.sha}`, +// // // renderOptions: { +// // // // dark: { +// // // // backgroundColor: `rgba(255, 255, 255, ${alphas.get(l.sha)})` +// // // // }, +// // // before: { +// // // contentText: `${l.sha}`, +// // // margin: '0 0 0 -10px' +// // // }, +// // // after: { +// // // contentText: `${l.sha}`, +// // // color: 'rbg(128, 128, 128)', +// // // margin: '0 20px 0 0' +// // // } +// // // } +// // // }; +// // // })); +// // //}) +// // }); + +// // // If the command is executed manually -- treat it as a click on the root lens (i.e. show blame for the whole file) +// // if (!uri) { +// // const doc = window.activeTextEditor && window.activeTextEditor.document; +// // if (doc) { +// // uri = doc.uri; +// // range = doc.validateRange(new Range(0, 0, 1000000, 1000000)); +// // position = doc.validateRange(new Range(0, 0, 0, 1000000)).start; +// // } + +// // if (!uri) return; +// // } + +// // return this.git.getBlameLocations(uri.path, range).then(locations => { +// // return commands.executeCommand(VsCodeCommands.ShowReferences, uri, position, locations); +// // }); +// } +// } + export class DiffWithPreviousCommand extends Command { constructor(private git: GitProvider) { super(Commands.DiffWithPrevious); diff --git a/src/extension.ts b/src/extension.ts index 0c45591..8e6ff10 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,9 @@ 'use strict'; -import {CodeLens, DocumentSelector, ExtensionContext, extensions, languages, workspace} from 'vscode'; +import {CodeLens, DocumentSelector, ExtensionContext, extensions, languages, OverviewRulerLane, window, workspace} from 'vscode'; import GitCodeLensProvider from './gitCodeLensProvider'; import GitBlameCodeLensProvider from './gitBlameCodeLensProvider'; import GitBlameContentProvider from './gitBlameContentProvider'; +import GitBlameController from './gitBlameController'; import GitProvider from './gitProvider'; import {BlameCommand, DiffWithPreviousCommand, DiffWithWorkingCommand} from './commands'; import {WorkspaceState} from './constants'; @@ -28,7 +29,11 @@ export function activate(context: ExtensionContext) { context.subscriptions.push(workspace.registerTextDocumentContentProvider(GitBlameContentProvider.scheme, new GitBlameContentProvider(context, git))); context.subscriptions.push(languages.registerCodeLensProvider(GitCodeLensProvider.selector, new GitCodeLensProvider(context, git))); context.subscriptions.push(languages.registerCodeLensProvider(GitBlameCodeLensProvider.selector, new GitBlameCodeLensProvider(context, git))); - context.subscriptions.push(new BlameCommand(git)); + + const blameController = new GitBlameController(context, git); + context.subscriptions.push(blameController); + + context.subscriptions.push(new BlameCommand(git, blameController)); context.subscriptions.push(new DiffWithPreviousCommand(git)); context.subscriptions.push(new DiffWithWorkingCommand(git)); }).catch(reason => console.warn(reason)); diff --git a/src/git.ts b/src/git.ts index f770b6a..29c6de9 100644 --- a/src/git.ts +++ b/src/git.ts @@ -22,12 +22,12 @@ export default class Git { fileName = Git.normalizePath(fileName, repoPath); if (sha) { - console.log('git', 'blame', '-fnw', '--root', `${sha}^`, '--', fileName); + console.log('git', 'blame', '-fn', '--root', `${sha}^`, '--', fileName); return gitCommand(repoPath, 'blame', '-fnw', '--root', `${sha}^`, '--', fileName); } - console.log('git', 'blame', '-fnw', '--root', '--', fileName); - return gitCommand(repoPath, 'blame', '-fnw', '--root', '--', fileName); + console.log('git', 'blame', '-fn', '--root', '--', fileName); + return gitCommand(repoPath, 'blame', '-fn', '--root', '--', fileName); // .then(s => { console.log(s); return s; }) // .catch(ex => console.error(ex)); } @@ -75,4 +75,13 @@ export default class Git { // .then(s => { console.log(s); return s; }) // .catch(ex => console.error(ex)); } + + static getCommitMessages(fileName: string, repoPath: string) { + fileName = Git.normalizePath(fileName, repoPath); + + console.log('git', 'log', '--oneline', '--', fileName); + return gitCommand(repoPath, 'log', '--oneline', '--', fileName); + // .then(s => { console.log(s); return s; }) + // .catch(ex => console.error(ex)); + } } \ No newline at end of file diff --git a/src/gitBlameController.ts b/src/gitBlameController.ts new file mode 100644 index 0000000..9a3611b --- /dev/null +++ b/src/gitBlameController.ts @@ -0,0 +1,185 @@ +'use strict' +import {commands, DecorationOptions, Disposable, ExtensionContext, OverviewRulerLane, Position, Range, TextEditor, TextEditorDecorationType, Uri, window, workspace} from 'vscode'; +import {Commands, VsCodeCommands} from './constants'; +import GitProvider, {IGitBlame} from './gitProvider'; +import {basename} from 'path'; +import * as moment from 'moment'; + +export default class GitBlameController extends Disposable { + private _controller: GitBlameEditorController; + private _subscription: Disposable; + + private blameDecoration: TextEditorDecorationType; + private highlightDecoration: TextEditorDecorationType; + + constructor(context: ExtensionContext, private git: GitProvider) { + super(() => this.dispose()); + + this.blameDecoration = window.createTextEditorDecorationType({ + before: { + color: '#5a5a5a', + margin: '0 1em 0 0', + width: '5em' + }, + }); + + this.highlightDecoration= window.createTextEditorDecorationType({ + dark: { + backgroundColor: 'rgba(255, 255, 255, 0.15)', + gutterIconPath: context.asAbsolutePath('images/blame-dark.png'), + overviewRulerColor: 'rgba(255, 255, 255, 0.75)', + }, + light: { + backgroundColor: 'rgba(0, 0, 0, 0.15)', + gutterIconPath: context.asAbsolutePath('images/blame-light.png'), + overviewRulerColor: 'rgba(0, 0, 0, 0.75)', + }, + gutterIconSize: 'contain', + overviewRulerLane: OverviewRulerLane.Right, + isWholeLine: true + }); + + this._subscription = Disposable.from(window.onDidChangeActiveTextEditor(e => { + if (!this._controller || this._controller.editor === e) return; + this.clear(); + })); + } + + dispose() { + this.clear(); + this._subscription && this._subscription.dispose(); + } + + clear() { + this._controller && this._controller.dispose(); + this._controller = null; + } + + toggleBlame(editor: TextEditor, sha: string) { + if (editor && (this._controller && this._controller.sha !== sha)) { + this._controller.applyHighlight(sha); + return; + } + + const controller = this._controller; + this.clear(); + + if (!editor || (controller && controller.sha === sha)) { + return; + } + + this._controller = new GitBlameEditorController(this.git, this.blameDecoration, this.highlightDecoration, editor, sha); + return this._controller.applyBlame(sha); + } +} + +class GitBlameEditorController extends Disposable { + private _subscription: Disposable; + private _blame: Promise; + private _commits: Promise>; + + constructor(private git: GitProvider, private blameDecoration: TextEditorDecorationType, private highlightDecoration: TextEditorDecorationType, public editor: TextEditor, public sha: string) { + super(() => this.dispose()); + + const fileName = this.editor.document.uri.path; + this._blame = this.git.getBlameForFile(fileName); + this._commits = this.git.getCommitMessages(fileName); + + this._subscription = Disposable.from(window.onDidChangeTextEditorSelection(e => { + const activeLine = e.selections[0].active.line; + this.git.getBlameForLine(e.textEditor.document.fileName, activeLine) + .then(blame => this.applyHighlight(blame.commit.sha)); + })); + } + + dispose() { + if (this.editor) { + this.editor.setDecorations(this.blameDecoration, []); + this.editor.setDecorations(this.highlightDecoration, []); + this.editor = null; + } + + this._subscription && this._subscription.dispose(); + } + + applyBlame(sha: string) { + return this._blame.then(blame => { + if (!blame.lines.length) return; + + return this._commits.then(msgs => { + const commits = Array.from(blame.commits.values()); + commits.forEach(c => c.message = msgs.get(c.sha.substring(0, c.sha.length - 1))); + + const blameDecorationOptions: DecorationOptions[] = blame.lines.map(l => { + const c = blame.commits.get(l.sha); + return { + range: this.editor.document.validateRange(new Range(l.line, 0, l.line, 0)), + hoverMessage: `${c.message}\n${c.author}, ${moment(c.date).format('MMMM Do, YYYY hh:MM a')}`, + renderOptions: { before: { contentText: `${l.sha}`, } } + }; + }); + + this.editor.setDecorations(this.blameDecoration, blameDecorationOptions); + return this.applyHighlight(sha || commits.sort((a, b) => b.date.getTime() - a.date.getTime())[0].sha); + }); + }); + } + + applyHighlight(sha: string) { + this.sha = sha; + return this._blame.then(blame => { + if (!blame.lines.length) return; + + const highlightDecorationRanges = blame.lines + .filter(l => l.sha === sha) + .map(l => this.editor.document.validateRange(new Range(l.line, 0, l.line, 1000000))); + + this.editor.setDecorations(this.highlightDecoration, highlightDecorationRanges); + }); + } + + // execute(sha?: string) { + // const editor = this.editor; + // const uri = editor.document.uri; + // const range = this.range; + + // // editor.setDecorations(this.blameDecoration, []); + // // editor.setDecorations(this.highlightDecoration, []); + + // const highlightDecorationRanges: Array = []; + // const blameDecorationOptions: Array = []; + + // this.git.getBlameForRange(uri.path, range).then(blame => { + // if (!blame.lines.length) return; + + // const commits = Array.from(blame.commits.values()); + // if (!sha) { + // sha = commits.sort((a, b) => b.date.getTime() - a.date.getTime())[0].sha; + // } + + // return this.git.getCommitMessages(uri.path) + // .then(msgs => { + // commits.forEach(c => { + // c.message = msgs.get(c.sha.substring(0, c.sha.length - 1)); + // }); + + // blame.lines.forEach(l => { + // if (l.sha === sha) { + // highlightDecorationRanges.push(editor.document.validateRange(new Range(l.line, 0, l.line, 1000000))); + // } + + // const c = blame.commits.get(l.sha); + // blameDecorationOptions.push({ + // range: editor.document.validateRange(new Range(l.line, 0, l.line, 0)), + // hoverMessage: `${c.sha}: ${c.message}\n${c.author}, ${moment(c.date).format('MMMM Do, YYYY hh:MM a')}`, + // renderOptions: { before: { contentText: `${l.sha}`, } } + // }); + // }); + // }); + // }) + // .then(() => { + // editor.setDecorations(this.blameDecoration, blameDecorationOptions); + // editor.setDecorations(this.highlightDecoration, highlightDecorationRanges); + // }); + // } +} \ No newline at end of file diff --git a/src/gitCodeLensProvider.ts b/src/gitCodeLensProvider.ts index e27ce0e..65bbc65 100644 --- a/src/gitCodeLensProvider.ts +++ b/src/gitCodeLensProvider.ts @@ -5,6 +5,8 @@ import GitProvider, {IGitBlame, IGitBlameCommit} from './gitProvider'; import * as moment from 'moment'; export class GitCodeLens extends CodeLens { + public sha: string; + constructor(private git: GitProvider, public fileName: string, public blameRange: Range, range: Range) { super(range); } @@ -68,6 +70,9 @@ export default class GitCodeLensProvider implements CodeLensProvider { } const line = document.lineAt(symbol.location.range.start); + if (lenses.length && lenses[lenses.length - 1].range.start.line === line.lineNumber) { + return; + } let startChar = line.text.search(`\\b${symbol.name}\\b`); //line.firstNonWhitespaceCharacterIndex; if (startChar === -1) { @@ -97,10 +102,11 @@ export default class GitCodeLensProvider implements CodeLensProvider { } const recentCommit = Array.from(blame.commits.values()).sort((a, b) => b.date.getTime() - a.date.getTime())[0]; + lens.sha = recentCommit.sha; lens.command = { title: `${recentCommit.author}, ${moment(recentCommit.date).fromNow()}`, // - lines(${lens.blameRange.start.line + 1}-${lens.blameRange.end.line + 1})`, command: Commands.ShowBlameHistory, - arguments: [Uri.file(lens.fileName), lens.blameRange, lens.range.start] + arguments: [Uri.file(lens.fileName), lens.blameRange, lens.sha] }; resolve(lens); }); diff --git a/src/gitProvider.ts b/src/gitProvider.ts index b23a069..a5defc8 100644 --- a/src/gitProvider.ts +++ b/src/gitProvider.ts @@ -7,6 +7,7 @@ import * as moment from 'moment'; import * as _ from 'lodash'; const blameMatcher = /^([\^0-9a-fA-F]{8})\s([\S]*)\s+([0-9\S]+)\s\((.*)\s([0-9]{4}-[0-9]{2}-[0-9]{2}\s[0-9]{2}:[0-9]{2}:[0-9]{2}\s[-|+][0-9]{4})\s+([0-9]+)\)(.*)$/gm; +const commitMessageMatcher = /^([\^0-9a-fA-F]{7})\s(.*)$/gm; export default class GitProvider extends Disposable { public repoPath: string; @@ -27,7 +28,6 @@ export default class GitProvider extends Disposable { dispose() { this._blames.clear(); this._subscription && this._subscription.dispose(); - super.dispose(); } private _removeFile(fileName: string) { @@ -38,10 +38,6 @@ export default class GitProvider extends Disposable { return Git.repoPath(cwd); } - getCommitMessage(sha: string) { - return Git.getCommitMessage(sha, this.repoPath); - } - getBlameForFile(fileName: string) { fileName = Git.normalizePath(fileName, this.repoPath); @@ -87,6 +83,16 @@ export default class GitProvider extends Disposable { return blame; } + getBlameForLine(fileName: string, line: number): Promise<{commit: IGitBlameCommit, line: IGitBlameLine}> { + return this.getBlameForFile(fileName).then(blame => { + const blameLine = blame.lines[line]; + return { + commit: blame.commits.get(blameLine.sha), + line: blameLine + }; + }); + } + getBlameForRange(fileName: string, range: Range): Promise { return this.getBlameForFile(fileName).then(blame => { if (!blame.lines.length) return blame; @@ -129,6 +135,22 @@ export default class GitProvider extends Disposable { }); } + getCommitMessage(sha: string) { + return Git.getCommitMessage(sha, this.repoPath); + } + + getCommitMessages(fileName: string) { + return Git.getCommitMessages(fileName, this.repoPath).then(data => { + const commits: Map = new Map(); + let m: Array; + while ((m = commitMessageMatcher.exec(data)) != null) { + commits.set(m[1], m[2]); + } + + return commits; + }); + } + getVersionedFile(fileName: string, sha: string) { return Git.getVersionedFile(fileName, this.repoPath, sha); } @@ -168,6 +190,7 @@ export interface IGitBlameCommit { fileName: string; author: string; date: Date; + message?: string; } export interface IGitBlameLine {