diff --git a/blame.png b/images/blame-dark.png similarity index 97% rename from blame.png rename to images/blame-dark.png index 22f8bcd..a76cb1e 100644 Binary files a/blame.png and b/images/blame-dark.png differ diff --git a/images/blame-light.png b/images/blame-light.png new file mode 100644 index 0000000..02b1947 Binary files /dev/null and b/images/blame-light.png differ diff --git a/package.json b/package.json index 21936f2..6b75076 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,11 @@ "postinstall": "node ./node_modules/vscode/bin/install && tsc" }, "dependencies": { - "tmp": "^0.0.28" + "tmp": "^0.0.28", + "spawn-rx": "^2.0.1" }, "devDependencies": { "typescript": "^1.8.10", - "vscode": "^0.11.15" + "vscode": "^0.11.17" } } \ No newline at end of file diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index 96738ec..ae489c5 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -2,7 +2,7 @@ import {CancellationToken, CodeLens, CodeLensProvider, commands, Location, Position, Range, SymbolInformation, SymbolKind, TextDocument, Uri} from 'vscode'; import {Commands, VsCodeCommands} from './constants'; import {IGitBlameLine, gitBlame} from './git'; -import {toGitBlameUri} from './contentProvider'; +import {toGitBlameUri} from './gitBlameUri'; import * as moment from 'moment'; export class GitBlameCodeLens extends CodeLens { @@ -34,7 +34,7 @@ export default class GitCodeLensProvider implements CodeLensProvider { provideCodeLenses(document: TextDocument, token: CancellationToken): CodeLens[] | Thenable { // TODO: Should I wait here? - let blame = gitBlame(document.fileName); + const blame = gitBlame(document.fileName); return (commands.executeCommand(VsCodeCommands.ExecuteDocumentSymbolProvider, document.uri) as Promise).then(symbols => { let lenses: CodeLens[] = []; @@ -44,6 +44,7 @@ export default class GitCodeLensProvider implements CodeLensProvider { if (!lenses.find(l => l.range.start.line === 0 && l.range.end.line === 0)) { const docRange = document.validateRange(new Range(0, 1000000, 1000000, 1000000)); lenses.push(new GitBlameCodeLens(blame, this.repoPath, document.fileName, docRange, new Range(0, 0, 0, docRange.start.character))); + lenses.push(new GitHistoryCodeLens(this.repoPath, document.fileName, docRange.with(new Position(docRange.start.line, docRange.start.character + 1)))); } return lenses; }); @@ -66,67 +67,66 @@ export default class GitCodeLensProvider implements CodeLensProvider { return; } - var line = document.lineAt(symbol.location.range.start); + const line = document.lineAt(symbol.location.range.start); lenses.push(new GitBlameCodeLens(blame, this.repoPath, document.fileName, symbol.location.range, line.range.with(new Position(line.range.start.line, line.firstNonWhitespaceCharacterIndex)))); lenses.push(new GitHistoryCodeLens(this.repoPath, document.fileName, line.range.with(new Position(line.range.start.line, line.firstNonWhitespaceCharacterIndex + 1)))); } resolveCodeLens(lens: CodeLens, token: CancellationToken): Thenable { - if (lens instanceof GitBlameCodeLens) { - return lens.getBlameLines().then(lines => { - if (!lines.length) { - console.error('No blame lines found', lens); - throw new Error('No blame lines found'); - } - - let recentLine = lines[0]; - - let locations: Location[] = []; - if (lines.length > 1) { - let sorted = lines.sort((a, b) => b.date.getTime() - a.date.getTime()); - recentLine = sorted[0]; - - 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]); - } - }); - - Array.from(map.values()).forEach((lines, i) => { - const uri = GitBlameCodeLens.toUri(lens, i + 1, lines[0], lines); - lines.forEach(l => { - locations.push(new Location(uri, new Position(l.originalLine, 0))); - }); - }); - - //locations = Array.from(map.values()).map((l, i) => new Location(GitBlameCodeLens.toUri(lens, i, l[0], l), new Position(l[0].originalLine, 0)));//lens.range.start)) - } else { - locations = [new Location(GitBlameCodeLens.toUri(lens, 1, recentLine, lines), lens.range.start)]; - } - - lens.command = { - title: `${recentLine.author}, ${moment(recentLine.date).fromNow()}`, - command: Commands.ShowBlameHistory, - arguments: [Uri.file(lens.fileName), lens.range.start, locations] - }; - return lens; - }).catch(ex => Promise.reject(ex)); // TODO: Figure out a better way to stop the codelens from appearing - } + if (lens instanceof GitBlameCodeLens) return this._resolveGitBlameCodeLens(lens, token); + if (lens instanceof GitHistoryCodeLens) return this._resolveGitHistoryCodeLens(lens, token); + } + + _resolveGitBlameCodeLens(lens: GitBlameCodeLens, token: CancellationToken): Thenable { + return lens.getBlameLines().then(lines => { + if (!lines.length) { + console.error('No blame lines found', lens); + throw new Error('No blame lines found'); + } + + let recentLine = lines[0]; + + let locations: Location[] = []; + if (lines.length > 1) { + let sorted = lines.sort((a, b) => b.date.getTime() - a.date.getTime()); + recentLine = sorted[0]; + + 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]); + } + }); + + Array.from(map.values()).forEach((lines, i) => { + const uri = GitBlameCodeLens.toUri(lens, i + 1, lines[0], lines); + lines.forEach(l => locations.push(new Location(uri, new Position(l.originalLine, 0)))); + }); + } else { + locations = [new Location(GitBlameCodeLens.toUri(lens, 1, recentLine, lines), lens.range.start)]; + } - // TODO: Play with this more -- get this to open the correct diff to the right place - if (lens instanceof GitHistoryCodeLens) { lens.command = { - title: `View Diff`, - command: 'git.viewFileHistory', // viewLineHistory - arguments: [Uri.file(lens.fileName)] + title: `${recentLine.author}, ${moment(recentLine.date).fromNow()}`, + command: Commands.ShowBlameHistory, + arguments: [Uri.file(lens.fileName), lens.range.start, locations] }; - return Promise.resolve(lens); - } + return lens; + }).catch(ex => Promise.reject(ex)); // TODO: Figure out a better way to stop the codelens from appearing + } + + _resolveGitHistoryCodeLens(lens: GitHistoryCodeLens, token: CancellationToken): Thenable { + // TODO: Play with this more -- get this to open the correct diff to the right place + lens.command = { + title: `View History`, + command: 'git.viewFileHistory', // viewLineHistory + arguments: [Uri.file(lens.fileName)] + }; + return Promise.resolve(lens); } } diff --git a/src/contentProvider.ts b/src/contentProvider.ts index 0847eb1..b5ef73b 100644 --- a/src/contentProvider.ts +++ b/src/contentProvider.ts @@ -1,8 +1,8 @@ 'use strict'; import {Disposable, EventEmitter, ExtensionContext, OverviewRulerLane, Range, TextEditor, TextEditorDecorationType, TextDocumentContentProvider, Uri, window, workspace} from 'vscode'; import {DocumentSchemes} from './constants'; -import {gitGetVersionFile, gitGetVersionText, IGitBlameLine} from './git'; -import {basename, dirname, extname, join} from 'path'; +import {gitGetVersionText} from './git'; +import {fromGitBlameUri, IGitBlameUriData} from './gitBlameUri'; import * as moment from 'moment'; export default class GitBlameContentProvider implements TextDocumentContentProvider { @@ -10,21 +10,29 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi private _blameDecoration: TextEditorDecorationType; private _onDidChange = new EventEmitter(); - private _subscriptions: Disposable; + // private _subscriptions: Disposable; // private _dataMap: Map; constructor(context: ExtensionContext) { - // TODO: Light & Dark this._blameDecoration = window.createTextEditorDecorationType({ - backgroundColor: 'rgba(254, 220, 95, 0.15)', - gutterIconPath: context.asAbsolutePath('blame.png'), - overviewRulerColor: 'rgba(254, 220, 95, 0.60)', + 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._dataMap = new Map(); // this._subscriptions = Disposable.from( + // window.onDidChangeActiveTextEditor(e => e ? console.log(e.document.uri) : console.log('active missing')), // workspace.onDidOpenTextDocument(d => { // let data = this._dataMap.get(d.uri.toString()); // if (!data) return; @@ -40,7 +48,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi dispose() { this._onDidChange.dispose(); - this._subscriptions && this._subscriptions.dispose(); + // this._subscriptions && this._subscriptions.dispose(); } get onDidChange() { @@ -57,7 +65,6 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi //const editor = this._findEditor(Uri.file(join(data.repoPath, data.file))); - //console.log('provideTextDocumentContent', uri, data); return gitGetVersionText(data.repoPath, data.sha, data.file).then(text => { this.update(uri); @@ -81,6 +88,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi private _findEditor(uri: Uri): TextEditor { let uriString = uri.toString(); + // TODO: This is a big hack :) const matcher = (e: any) => (e._documentData && e._documentData._uri && e._documentData._uri.toString()) === uriString; if (matcher(window.activeTextEditor)) { return window.activeTextEditor; @@ -89,6 +97,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi } private _tryAddBlameDecorations(uri: Uri, data: IGitBlameUriData) { + // Needs to be on a timer for some reason because we won't find the editor otherwise -- is there an event? let handle = setInterval(() => { let editor = this._findEditor(uri); if (editor) { @@ -96,7 +105,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi editor.setDecorations(this._blameDecoration, data.lines.map(l => { return { range: editor.document.validateRange(new Range(l.originalLine, 0, l.originalLine, 1000000)), - hoverMessage: `${moment(l.date).fromNow()}\n${l.author}\n${l.sha}` + hoverMessage: `${moment(l.date).format('MMMM Do, YYYY hh:MMa')}\n${l.author}\n${l.sha}` }; })); } @@ -106,23 +115,4 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi // private _addBlameDecorations(editor: TextEditor, data: IGitBlameUriData) { // editor.setDecorations(this._blameDecoration, data.lines.map(l => editor.document.validateRange(new Range(l.line, 0, l.line, 1000000)))); // } -} - -export interface IGitBlameUriData extends IGitBlameLine { - repoPath: string, - range: Range, - index: number, - 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}:${data.index}. ${moment(data.date).format('YYYY-MM-DD hh:MMa')} ${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/git.ts b/src/git.ts index 18f0d3f..ace42be 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,8 +1,8 @@ 'use strict'; -import {spawn} from 'child_process'; import {basename, dirname, extname} from 'path'; import * as fs from 'fs'; import * as tmp from 'tmp'; +import {spawnPromise} from 'spawn-rx'; export declare interface IGitBlameLine { sha: string; @@ -14,28 +14,14 @@ export declare interface IGitBlameLine { code: string; } -export function gitRepoPath(cwd): Promise { - let data: Array = []; - const capture = input => data.push(input.toString().replace(/\r?\n|\r/g, '')); - const output = () => data[0]; - - return gitCommand(cwd, capture, output, 'rev-parse', '--show-toplevel'); - - // return new Promise((resolve, reject) => { - // gitCommand(cwd, capture, output, 'rev-parse', '--show-toplevel') - // .then(result => resolve(result[0])) - // .catch(reason => reject(reason)); - // }); +export function gitRepoPath(cwd) { + return gitCommand(cwd, 'rev-parse', '--show-toplevel').then(data => data.replace(/\r?\n|\r/g, '')); } -//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; 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): Promise { - let data: string = ''; - const capture = input => data += input.toString(); - const output = () => { +export function gitBlame(fileName: string) { + return gitCommand(dirname(fileName), 'blame', '-fnw', '--', fileName).then(data => { let lines: Array = []; let m: Array; while ((m = blameMatcher.exec(data)) != null) { @@ -50,18 +36,12 @@ export function gitBlame(fileName: string): Promise { }); } return lines; - }; - - return gitCommand(dirname(fileName), capture, output, 'blame', '-fnw', '--', fileName); + }); } -export function gitGetVersionFile(repoPath: string, sha: string, source: string): Promise { - let data: Array = []; - const capture = input => data.push(input); - const output = () => data; - +export function gitGetVersionFile(repoPath: string, sha: string, source: string): Promise { return new Promise((resolve, reject) => { - (gitCommand(repoPath, capture, output, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => { + gitCommand(repoPath, 'show', `${sha}:${source.replace(/\\/g, '/')}`).then(data => { let ext = extname(source); tmp.file({ prefix: `${basename(source, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { if (err) { @@ -72,7 +52,7 @@ export function gitGetVersionFile(repoPath: string, sha: string, source: string) console.log("File: ", destination); console.log("Filedescriptor: ", fd); - fs.appendFile(destination, o.join(), err => { + fs.appendFile(destination, data, err => { if (err) { reject(err); return; @@ -84,39 +64,10 @@ export function gitGetVersionFile(repoPath: string, sha: string, source: string) }); } -export function gitGetVersionText(repoPath: string, sha: string, source: string): Promise { - let data: Array = []; - const capture = input => data.push(input.toString()); - const output = () => data; - - return new Promise((resolve, reject) => (gitCommand(repoPath, capture, output, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => resolve(o.join()))); +export function gitGetVersionText(repoPath: string, sha: string, source: string) { + return gitCommand(repoPath, 'show', `${sha}:${source.replace(/\\/g, '/')}`); } -function gitCommand(cwd: string, capture: (input: Buffer) => void, output: () => any, ...args): Promise { - return new Promise((resolve, reject) => { - let spawn = require('child_process').spawn; - let process = spawn('git', args, { cwd: cwd }); - - process.stdout.on('data', data => { - capture(data); - }); - - let errors: Array = []; - process.stderr.on('data', err => { - errors.push(err.toString()); - }); - - process.on('close', (exitCode, exitSignal) => { - if (exitCode && errors.length) { - reject(errors.toString()); - return; - } - - try { - resolve(output()); - } catch (ex) { - reject(ex); - } - }); - }); +function gitCommand(cwd: string, ...args) { + return spawnPromise('git', args, { cwd: cwd }); } \ No newline at end of file diff --git a/src/gitBlameUri.ts b/src/gitBlameUri.ts new file mode 100644 index 0000000..4504acf --- /dev/null +++ b/src/gitBlameUri.ts @@ -0,0 +1,27 @@ +import {Range, Uri} from 'vscode'; +import {DocumentSchemes} from './constants'; +import {IGitBlameLine} from './git'; +import {basename, dirname, extname} from 'path'; +import * as moment from 'moment'; + +export interface IGitBlameUriData extends IGitBlameLine { + repoPath: string, + range: Range, + index: number, + lines: IGitBlameLine[] +} + +export function toGitBlameUri(data: IGitBlameUriData) { + const pad = n => ("000" + n).slice(-3); + + let ext = extname(data.file); + let path = `${dirname(data.file)}/${data.sha}: ${basename(data.file, ext)}${ext}`; + // TODO: Need to specify an index here, since I can't control the sort order -- just alphabetic or by file location + return Uri.parse(`${DocumentSchemes.GitBlame}:${pad(data.index)}. ${data.author}, ${moment(data.date).format('MMM D, YYYY hh:MMa')} - ${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/typings/spawn-rx.d.ts b/typings/spawn-rx.d.ts new file mode 100644 index 0000000..a0ee414 --- /dev/null +++ b/typings/spawn-rx.d.ts @@ -0,0 +1,13 @@ +/// +declare module "spawn-rx" { + import { Observable } from 'rxjs/Observable'; + + namespace spawnrx { + function findActualExecutable(exe: string, args: Array): { cmd: string, args: Array }; + function spawnDetached(exe: string, params: Array, opts: Object): Observable; + function spawn(exe: string, params: Array, opts: Object): Observable; + function spawnDetachedPromise(exe: string, params: Array, opts: Object): Promise; + function spawnPromise(exe: string, params: Array, opts: Object): Promise; + } + export = spawnrx; +} \ No newline at end of file