diff --git a/blame.png b/blame.png new file mode 100644 index 0000000..2152961 Binary files /dev/null and b/blame.png differ diff --git a/package.json b/package.json index 32c72b8..21936f2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,11 @@ ], "main": "./out/src/extension", "contributes": { + "commands": [{ + "command": "git.codelen.showBlameHistory", + "title": "Show Blame History", + "category": "Git" + }] }, "scripts": { "vscode:prepublish": "node ./node_modules/vscode/bin/compile", @@ -26,6 +31,7 @@ "postinstall": "node ./node_modules/vscode/bin/install && tsc" }, "dependencies": { + "tmp": "^0.0.28" }, "devDependencies": { "typescript": "^1.8.10", diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index 20a60a4..e385f4f 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -1,15 +1,21 @@ 'use strict'; -import {CancellationToken, CodeLens, CodeLensProvider, commands, Range, SymbolInformation, SymbolKind, TextDocument, Uri} from 'vscode'; -import {IBlameLine, gitBlame} from './git'; +import {CancellationToken, CodeLens, CodeLensProvider, commands, Location, Range, SymbolInformation, SymbolKind, TextDocument, Uri} from 'vscode'; +import {Commands, VsCodeCommands} from './constants'; +import {IGitBlameLine, gitBlame} from './git'; +import {toGitBlameUri} from './contentProvider'; import * as moment from 'moment'; export class GitCodeLens extends CodeLens { - constructor(public blame: Promise, public fileName: string, public blameRange: Range, range: Range) { + constructor(private blame: Promise, public repoPath: string, public fileName: string, private blameRange: Range, range: Range) { super(range); + } + + getBlameLines(): Promise { + return this.blame.then(allLines => allLines.slice(this.blameRange.start.line, this.blameRange.end.line + 1)); + } - this.blame = blame; - this.fileName = fileName; - this.blameRange = blameRange; + static toUri(lens: GitCodeLens, line: IGitBlameLine, lines: IGitBlameLine[]): Uri { + return toGitBlameUri(Object.assign({ repoPath: lens.repoPath, range: lens.blameRange, lines: lines }, line)); } } @@ -20,14 +26,14 @@ export default class GitCodeLensProvider implements CodeLensProvider { // TODO: Should I wait here? let blame = gitBlame(document.fileName); - return (commands.executeCommand('vscode.executeDocumentSymbolProvider', document.uri) as Promise).then(symbols => { + return (commands.executeCommand(VsCodeCommands.ExecuteDocumentSymbolProvider, document.uri) as Promise).then(symbols => { let lenses: CodeLens[] = []; symbols.forEach(sym => this._provideCodeLens(document, sym, blame, lenses)); return lenses; }); } - private _provideCodeLens(document: TextDocument, symbol: SymbolInformation, blame: Promise, lenses: CodeLens[]): void { + private _provideCodeLens(document: TextDocument, symbol: SymbolInformation, blame: Promise, lenses: CodeLens[]): void { switch (symbol.kind) { case SymbolKind.Module: case SymbolKind.Class: @@ -43,30 +49,46 @@ export default class GitCodeLensProvider implements CodeLensProvider { } var line = document.lineAt(symbol.location.range.start); - // if (line.text.includes(symbol.name)) { - // } - - let lens = new GitCodeLens(blame, document.fileName, symbol.location.range, line.range); + let lens = new GitCodeLens(blame, this.repoPath, document.fileName, symbol.location.range, line.range); lenses.push(lens); } - resolveCodeLens(codeLens: CodeLens, token: CancellationToken): Thenable { - if (codeLens instanceof GitCodeLens) { - return codeLens.blame.then(allLines => { - let lines = allLines.slice(codeLens.blameRange.start.line, codeLens.blameRange.end.line + 1); - let line = lines[0]; + resolveCodeLens(lens: CodeLens, token: CancellationToken): Thenable { + if (lens instanceof GitCodeLens) { + return lens.getBlameLines().then(lines => { + let recentLine = lines[0]; + + let locations: Location[] = []; if (lines.length > 1) { - let sorted = lines.sort((a, b) => b.date.getTime() - a.date.getTime()); - line = sorted[0]; + let sorted = lines.sort((a, b) => a.date.getTime() - b.date.getTime()); + recentLine = sorted[sorted.length - 1]; + + console.log(lens.fileName, 'Blame lines:', sorted); + + let map: Map = new Map(); + sorted.forEach(l => { + let item = map.get(l.sha); + if (item) { + item.push(l); + } else { + map.set(l.sha, [l]); + } + }); + + locations = Array.from(map.values()).map(l => new Location(GitCodeLens.toUri(lens, l[0], l), lens.range.start)) + } else { + locations = [new Location(GitCodeLens.toUri(lens, recentLine, lines), lens.range.start)]; } - codeLens.command = { - title: `${line.author}, ${moment(line.date).fromNow()}`, - command: 'git.viewFileHistory', - arguments: [Uri.file(codeLens.fileName)] + lens.command = { + title: `${recentLine.author}, ${moment(recentLine.date).fromNow()}`, + command: Commands.ShowBlameHistory, + arguments: [Uri.file(lens.fileName), lens.range.start, locations] + // command: 'git.viewFileHistory', + // arguments: [Uri.file(codeLens.fileName)] }; - return codeLens; - });//.catch(ex => Promise.reject(ex)); // TODO: Figure out a better way to stop the codelens from appearing + return lens; + }).catch(ex => Promise.reject(ex)); // TODO: Figure out a better way to stop the codelens from appearing } } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..43d3d8d --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,15 @@ +export type Commands = 'git.action.showBlameHistory'; +export const Commands = { + ShowBlameHistory: 'git.action.showBlameHistory' as Commands +} + +export type DocumentSchemes = 'gitblame'; +export const DocumentSchemes = { + GitBlame: 'gitblame' as DocumentSchemes +} + +export type VsCodeCommands = 'vscode.executeDocumentSymbolProvider' | 'editor.action.showReferences'; +export const VsCodeCommands = { + ExecuteDocumentSymbolProvider: 'vscode.executeDocumentSymbolProvider' as VsCodeCommands, + ShowReferences: 'editor.action.showReferences' as VsCodeCommands +} \ No newline at end of file diff --git a/src/contentProvider.ts b/src/contentProvider.ts new file mode 100644 index 0000000..269a2d1 --- /dev/null +++ b/src/contentProvider.ts @@ -0,0 +1,81 @@ +'use strict'; +import {Disposable, EventEmitter, ExtensionContext, Location, OverviewRulerLane, Range, TextEditorDecorationType, TextDocumentContentProvider, Uri, window, workspace} from 'vscode'; +import {DocumentSchemes} from './constants'; +import {gitGetVersionFile, gitGetVersionText, IGitBlameLine} from './git'; +import {basename, dirname, extname} from 'path'; +import * as moment from 'moment'; + +export default class GitBlameContentProvider implements TextDocumentContentProvider { + static scheme = DocumentSchemes.GitBlame; + + private _blameDecoration: TextEditorDecorationType; + private _onDidChange = new EventEmitter(); + + constructor(context: ExtensionContext) { + let image = context.asAbsolutePath('blame.png'); + this._blameDecoration = window.createTextEditorDecorationType({ + backgroundColor: 'rgba(21, 251, 126, 0.7)', + gutterIconPath: image, + gutterIconSize: 'auto' + }); + } + + dispose() { + this._onDidChange.dispose(); + } + + get onDidChange() { + return this._onDidChange.event; + } + + public update(uri: Uri) { + this._onDidChange.fire(uri); + } + + provideTextDocumentContent(uri: Uri): string | Thenable { + const data = fromGitBlameUri(uri); + + console.log('provideTextDocumentContent', uri, data); + return gitGetVersionText(data.repoPath, data.sha, data.file).then(text => { + this.update(uri); + + setTimeout(() => { + let uriString = uri.toString(); + let editor = window.visibleTextEditors.find((e: any) => (e._documentData && e._documentData._uri && e._documentData._uri.toString()) === uriString); + if (editor) { + editor.setDecorations(this._blameDecoration, data.lines.map(l => new Range(l.line, 0, l.line, 1))); + } + }, 1500); + + // let foo = text.split('\n'); + // return foo.slice(data.range.start.line, data.range.end.line).join('\n') + return text; + }); + + // return gitGetVersionFile(data.repoPath, data.sha, data.file).then(dst => { + // let uri = Uri.parse(`file:${dst}`) + // return workspace.openTextDocument(uri).then(doc => { + // this.update(uri); + // return doc.getText(); + // }); + // }); + } +} + +export interface IGitBlameUriData extends IGitBlameLine { + repoPath: string, + range: Range, + lines: IGitBlameLine[] +} + +export function toGitBlameUri(data: IGitBlameUriData) { + let ext = extname(data.file); + let path = `${dirname(data.file)}/${data.sha}: ${basename(data.file, ext)}${ext}`; + return Uri.parse(`${DocumentSchemes.GitBlame}:${path}?${JSON.stringify(data)}`); +} + +export function fromGitBlameUri(uri: Uri): IGitBlameUriData { + let data = JSON.parse(uri.query); + data.range = new Range(data.range[0].line, data.range[0].character, data.range[1].line, data.range[1].character); + return data; +} \ No newline at end of file diff --git a/src/definitionProvider.ts b/src/definitionProvider.ts new file mode 100644 index 0000000..8382657 --- /dev/null +++ b/src/definitionProvider.ts @@ -0,0 +1,21 @@ +// 'use strict'; +// import {CancellationToken, CodeLens, commands, DefinitionProvider, Position, Location, TextDocument, Uri} from 'vscode'; +// import {GitCodeLens} from './codeLensProvider'; + +// export default class GitDefinitionProvider implements DefinitionProvider { +// public provideDefinition(document: TextDocument, position: Position, token: CancellationToken): Promise { +// return (commands.executeCommand('vscode.executeCodeLensProvider', document.uri) as Promise).then(lenses => { +// let matches: CodeLens[] = []; +// lenses.forEach(lens => { +// if (lens instanceof GitCodeLens && lens.blameRange.contains(position)) { +// matches.push(lens); +// } +// }); + +// if (matches.length) { +// return new Location(Uri.parse(), position); +// } +// return null; +// }); +// } +// } diff --git a/src/extension.ts b/src/extension.ts index ade6af3..0c0f268 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,9 @@ 'use strict'; -import {DocumentSelector, ExtensionContext, languages, workspace} from 'vscode'; +import {commands, DocumentSelector, ExtensionContext, languages, workspace} from 'vscode'; import GitCodeLensProvider from './codeLensProvider'; -import {gitRepoPath} from './git' +import GitContentProvider from './contentProvider'; +import {gitRepoPath} from './git'; +import {Commands, VsCodeCommands} from './constants'; // this method is called when your extension is activated export function activate(context: ExtensionContext) { @@ -11,6 +13,12 @@ export function activate(context: ExtensionContext) { } gitRepoPath(workspace.rootPath).then(repoPath => { + context.subscriptions.push(workspace.registerTextDocumentContentProvider(GitContentProvider.scheme, new GitContentProvider(context))); + + context.subscriptions.push(commands.registerCommand(Commands.ShowBlameHistory, (...args) => { + return commands.executeCommand(VsCodeCommands.ShowReferences, ...args); + })); + let selector: DocumentSelector = { scheme: 'file' }; context.subscriptions.push(languages.registerCodeLensProvider(selector, new GitCodeLensProvider(repoPath))); }).catch(reason => console.warn(reason)); diff --git a/src/git.ts b/src/git.ts index 8f2f94a..0ff3acb 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,13 +1,17 @@ 'use strict'; import {spawn} from 'child_process'; -import {dirname} from 'path'; +import {basename, dirname, extname} from 'path'; +import * as fs from 'fs'; +import * as tmp from 'tmp'; -export declare interface IBlameLine { - line: number; +export declare interface IGitBlameLine { + sha: string; + file: string; + originalLine: number; author: string; date: Date; - sha: string; - //code: string; + line: number; + code: string; } export function gitRepoPath(cwd) { @@ -22,33 +26,72 @@ export function gitRepoPath(cwd) { }); } -const blameMatcher = /^(.*)\t\((.*)\t(.*)\t(.*?)\)(.*)$/gm; +//const blameMatcher = /^(.*)\t\((.*)\t(.*)\t(.*?)\)(.*)$/gm; +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; -export function gitBlame(fileName: string) { +export function gitBlame(fileName: string): Promise { const mapper = (input, output) => { + let blame = input.toString(); + console.log(fileName, 'Blame:', blame); + let m: Array; - while ((m = blameMatcher.exec(input.toString())) != null) { + while ((m = blameMatcher.exec(blame)) != null) { output.push({ - line: parseInt(m[4], 10), - author: m[2], - date: new Date(m[3]), - sha: m[1] - //code: m[5] + sha: m[1], + file: m[2].trim(), + originalLine: parseInt(m[3], 10), + author: m[4].trim(), + date: new Date(m[5]), + line: parseInt(m[6], 10), + code: m[7] }); } }; - return gitCommand(dirname(fileName), mapper, 'blame', '-c', '-M', '-w', '--', fileName) as Promise; + return gitCommand(dirname(fileName), mapper, 'blame', '-fnw', '--', fileName); +} + +export function gitGetVersionFile(repoPath: string, sha: string, source: string): Promise { + const mapper = (input, output) => output.push(input); + + return new Promise((resolve, reject) => { + (gitCommand(repoPath, mapper, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => { + let ext = extname(source); + tmp.file({ prefix: `${basename(source, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { + if (err) { + reject(err); + return; + } + + console.log("File: ", destination); + console.log("Filedescriptor: ", fd); + + fs.appendFile(destination, o.join(), err => { + if (err) { + reject(err); + return; + } + resolve(destination); + }); + }); + }); + }); +} + +export function gitGetVersionText(repoPath: string, sha: string, source: string): Promise { + const mapper = (input, output) => output.push(input.toString()); + + return new Promise((resolve, reject) => (gitCommand(repoPath, mapper, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => resolve(o.join()))); } -function gitCommand(cwd: string, map: (input: Buffer, output: Array) => void, ...args): Promise { +function gitCommand(cwd: string, mapper: (input: Buffer, output: Array) => void, ...args): Promise { return new Promise((resolve, reject) => { let spawn = require('child_process').spawn; let process = spawn('git', args, { cwd: cwd }); let output: Array = []; process.stdout.on('data', data => { - map(data, output); + mapper(data, output); }); let errors: Array = []; diff --git a/typings.json b/typings.json new file mode 100644 index 0000000..89a1408 --- /dev/null +++ b/typings.json @@ -0,0 +1,5 @@ +{ + "globalDependencies": { + "tmp": "registry:dt/tmp#0.0.0+20160514170650" + } +} diff --git a/typings/globals/tmp/index.d.ts b/typings/globals/tmp/index.d.ts new file mode 100644 index 0000000..5ae8593 --- /dev/null +++ b/typings/globals/tmp/index.d.ts @@ -0,0 +1,45 @@ +// Generated by typings +// Source: https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/5d6115f046550714a28caa3555f47a1aec114fe5/tmp/tmp.d.ts +declare module "tmp" { + + namespace tmp { + interface Options extends SimpleOptions { + mode?: number; + } + + interface SimpleOptions { + prefix?: string; + postfix?: string; + template?: string; + dir?: string; + tries?: number; + keep?: boolean; + unsafeCleanup?: boolean; + } + + interface SynchrounousResult { + name: string; + fd: number; + removeCallback: () => void; + } + + function file(callback: (err: any, path: string, fd: number, cleanupCallback: () => void) => void): void; + function file(config: Options, callback?: (err: any, path: string, fd: number, cleanupCallback: () => void) => void): void; + + function fileSync(config?: Options): SynchrounousResult; + + function dir(callback: (err: any, path: string, cleanupCallback: () => void) => void): void; + function dir(config: Options, callback?: (err: any, path: string, cleanupCallback: () => void) => void): void; + + function dirSync(config?: Options): SynchrounousResult; + + function tmpName(callback: (err: any, path: string) => void): void; + function tmpName(config: SimpleOptions, callback?: (err: any, path: string) => void): void; + + function tmpNameSync(config?: SimpleOptions): string; + + function setGracefulCleanup(): void; + } + + export = tmp; +} diff --git a/typings/globals/tmp/typings.json b/typings/globals/tmp/typings.json new file mode 100644 index 0000000..f69d10a --- /dev/null +++ b/typings/globals/tmp/typings.json @@ -0,0 +1,8 @@ +{ + "resolution": "main", + "tree": { + "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/5d6115f046550714a28caa3555f47a1aec114fe5/tmp/tmp.d.ts", + "raw": "registry:dt/tmp#0.0.0+20160514170650", + "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/5d6115f046550714a28caa3555f47a1aec114fe5/tmp/tmp.d.ts" + } +} diff --git a/typings/index.d.ts b/typings/index.d.ts new file mode 100644 index 0000000..f98d791 --- /dev/null +++ b/typings/index.d.ts @@ -0,0 +1 @@ +///