diff --git a/blame.png b/blame.png index 2152961..22f8bcd 100644 Binary files a/blame.png and b/blame.png differ diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index e385f4f..96738ec 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -1,11 +1,11 @@ 'use strict'; -import {CancellationToken, CodeLens, CodeLensProvider, commands, Location, Range, SymbolInformation, SymbolKind, TextDocument, Uri} from 'vscode'; +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 * as moment from 'moment'; -export class GitCodeLens extends CodeLens { +export class GitBlameCodeLens extends CodeLens { constructor(private blame: Promise, public repoPath: string, public fileName: string, private blameRange: Range, range: Range) { super(range); } @@ -14,11 +14,21 @@ export class GitCodeLens extends CodeLens { return this.blame.then(allLines => allLines.slice(this.blameRange.start.line, this.blameRange.end.line + 1)); } - static toUri(lens: GitCodeLens, line: IGitBlameLine, lines: IGitBlameLine[]): Uri { - return toGitBlameUri(Object.assign({ repoPath: lens.repoPath, range: lens.blameRange, lines: lines }, line)); + static toUri(lens: GitBlameCodeLens, index: number, line: IGitBlameLine, lines: IGitBlameLine[]): Uri { + return toGitBlameUri(Object.assign({ repoPath: lens.repoPath, index: index, range: lens.blameRange, lines: lines }, line)); } } +export class GitHistoryCodeLens extends CodeLens { + constructor(public repoPath: string, public fileName: string, range: Range) { + super(range); + } + + // static toUri(lens: GitHistoryCodeLens, index: number): Uri { + // return toGitBlameUri(Object.assign({ repoPath: lens.repoPath, index: index, range: lens.blameRange, lines: lines }, line)); + // } +} + export default class GitCodeLensProvider implements CodeLensProvider { constructor(public repoPath: string) { } @@ -29,39 +39,52 @@ export default class GitCodeLensProvider implements CodeLensProvider { return (commands.executeCommand(VsCodeCommands.ExecuteDocumentSymbolProvider, document.uri) as Promise).then(symbols => { let lenses: CodeLens[] = []; symbols.forEach(sym => this._provideCodeLens(document, sym, blame, lenses)); + + // Check if we have a lens for the whole document -- if not add one + 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))); + } return lenses; }); } private _provideCodeLens(document: TextDocument, symbol: SymbolInformation, blame: Promise, lenses: CodeLens[]): void { switch (symbol.kind) { + case SymbolKind.Package: case SymbolKind.Module: case SymbolKind.Class: case SymbolKind.Interface: - case SymbolKind.Method: - case SymbolKind.Function: case SymbolKind.Constructor: - case SymbolKind.Field: + case SymbolKind.Method: case SymbolKind.Property: + case SymbolKind.Field: + case SymbolKind.Function: + case SymbolKind.Enum: break; default: return; } var line = document.lineAt(symbol.location.range.start); - let lens = new GitCodeLens(blame, this.repoPath, document.fileName, symbol.location.range, line.range); - lenses.push(lens); + 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 GitCodeLens) { + 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) => a.date.getTime() - b.date.getTime()); - recentLine = sorted[sorted.length - 1]; + let sorted = lines.sort((a, b) => b.date.getTime() - a.date.getTime()); + recentLine = sorted[0]; console.log(lens.fileName, 'Blame lines:', sorted); @@ -75,20 +98,35 @@ export default class GitCodeLensProvider implements CodeLensProvider { } }); - locations = Array.from(map.values()).map(l => new Location(GitCodeLens.toUri(lens, l[0], l), lens.range.start)) + 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(GitCodeLens.toUri(lens, recentLine, lines), lens.range.start)]; + 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] - // command: 'git.viewFileHistory', - // arguments: [Uri.file(codeLens.fileName)] }; return lens; }).catch(ex => Promise.reject(ex)); // TODO: Figure out a better way to stop the codelens from appearing } + + // 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)] + }; + return Promise.resolve(lens); + } } } diff --git a/src/contentProvider.ts b/src/contentProvider.ts index 269a2d1..0847eb1 100644 --- a/src/contentProvider.ts +++ b/src/contentProvider.ts @@ -1,8 +1,8 @@ 'use strict'; -import {Disposable, EventEmitter, ExtensionContext, Location, OverviewRulerLane, Range, TextEditorDecorationType, TextDocumentContentProvider, Uri, window, workspace} from 'vscode'; +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} from 'path'; +import {basename, dirname, extname, join} from 'path'; import * as moment from 'moment'; export default class GitBlameContentProvider implements TextDocumentContentProvider { @@ -10,18 +10,37 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi private _blameDecoration: TextEditorDecorationType; private _onDidChange = new EventEmitter(); + private _subscriptions: Disposable; + // private _dataMap: Map; constructor(context: ExtensionContext) { - let image = context.asAbsolutePath('blame.png'); + // TODO: Light & Dark this._blameDecoration = window.createTextEditorDecorationType({ - backgroundColor: 'rgba(21, 251, 126, 0.7)', - gutterIconPath: image, - gutterIconSize: 'auto' + backgroundColor: 'rgba(254, 220, 95, 0.15)', + gutterIconPath: context.asAbsolutePath('blame.png'), + overviewRulerColor: 'rgba(254, 220, 95, 0.60)', + overviewRulerLane: OverviewRulerLane.Right, + isWholeLine: true }); + + // this._dataMap = new Map(); + // this._subscriptions = Disposable.from( + // workspace.onDidOpenTextDocument(d => { + // let data = this._dataMap.get(d.uri.toString()); + // if (!data) return; + + // // TODO: This only works on the first load -- not after since it is cached + // this._tryAddBlameDecorations(d.uri, data); + // }), + // workspace.onDidCloseTextDocument(d => { + // this._dataMap.delete(d.uri.toString()); + // }) + // ); } dispose() { this._onDidChange.dispose(); + this._subscriptions && this._subscriptions.dispose(); } get onDidChange() { @@ -34,21 +53,20 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi provideTextDocumentContent(uri: Uri): string | Thenable { const data = fromGitBlameUri(uri); + // this._dataMap.set(uri.toString(), data); - console.log('provideTextDocumentContent', uri, data); + //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); - 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); + // TODO: This only works on the first load -- not after since it is cached + this._tryAddBlameDecorations(uri, data); + + // TODO: This needs to move to selection somehow to show on the main file editor + //this._addBlameDecorations(editor, data); - // let foo = text.split('\n'); - // return foo.slice(data.range.start.line, data.range.end.line).join('\n') return text; }); @@ -60,18 +78,47 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi // }); // }); } + + private _findEditor(uri: Uri): TextEditor { + let uriString = uri.toString(); + const matcher = (e: any) => (e._documentData && e._documentData._uri && e._documentData._uri.toString()) === uriString; + if (matcher(window.activeTextEditor)) { + return window.activeTextEditor; + } + return window.visibleTextEditors.find(matcher); + } + + private _tryAddBlameDecorations(uri: Uri, data: IGitBlameUriData) { + let handle = setInterval(() => { + let editor = this._findEditor(uri); + if (editor) { + clearInterval(handle); + 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}` + }; + })); + } + }, 200); + } + + // 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}:${path}?${JSON.stringify(data)}`); + 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 { diff --git a/src/extension.ts b/src/extension.ts index 0c0f268..7421398 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,7 +19,7 @@ export function activate(context: ExtensionContext) { return commands.executeCommand(VsCodeCommands.ShowReferences, ...args); })); - let selector: DocumentSelector = { scheme: 'file' }; + const 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 0ff3acb..18f0d3f 100644 --- a/src/git.ts +++ b/src/git.ts @@ -14,48 +14,54 @@ export declare interface IGitBlameLine { code: string; } -export function gitRepoPath(cwd) { - const mapper = (input, output) => { - output.push(input.toString().replace(/\r?\n|\r/g, '')) - }; +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 new Promise((resolve, reject) => { - gitCommand(cwd, mapper, 'rev-parse', '--show-toplevel') - .then(result => resolve(result[0])) - .catch(reason => reject(reason)); - }); + 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)); + // }); } //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; +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 { - const mapper = (input, output) => { - let blame = input.toString(); - console.log(fileName, 'Blame:', blame); - + let data: string = ''; + const capture = input => data += input.toString(); + const output = () => { + let lines: Array = []; let m: Array; - while ((m = blameMatcher.exec(blame)) != null) { - output.push({ + while ((m = blameMatcher.exec(data)) != null) { + lines.push({ sha: m[1], file: m[2].trim(), - originalLine: parseInt(m[3], 10), + originalLine: parseInt(m[3], 10) - 1, author: m[4].trim(), date: new Date(m[5]), - line: parseInt(m[6], 10), + line: parseInt(m[6], 10) - 1, code: m[7] }); } + return lines; }; - return gitCommand(dirname(fileName), mapper, 'blame', '-fnw', '--', fileName); + return gitCommand(dirname(fileName), capture, output, 'blame', '-fnw', '--', fileName); } export function gitGetVersionFile(repoPath: string, sha: string, source: string): Promise { - const mapper = (input, output) => output.push(input); + let data: Array = []; + const capture = input => data.push(input); + const output = () => data; return new Promise((resolve, reject) => { - (gitCommand(repoPath, mapper, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => { + (gitCommand(repoPath, capture, output, '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) { @@ -79,19 +85,20 @@ export function gitGetVersionFile(repoPath: string, sha: string, source: string) } export function gitGetVersionText(repoPath: string, sha: string, source: string): Promise { - const mapper = (input, output) => output.push(input.toString()); + let data: Array = []; + const capture = input => data.push(input.toString()); + const output = () => data; - return new Promise((resolve, reject) => (gitCommand(repoPath, mapper, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => resolve(o.join()))); + return new Promise((resolve, reject) => (gitCommand(repoPath, capture, output, 'show', `${sha}:${source.replace(/\\/g, '/')}`) as Promise>).then(o => resolve(o.join()))); } -function gitCommand(cwd: string, mapper: (input: Buffer, output: Array) => void, ...args): Promise { +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 }); - let output: Array = []; process.stdout.on('data', data => { - mapper(data, output); + capture(data); }); let errors: Array = []; @@ -105,7 +112,11 @@ function gitCommand(cwd: string, mapper: (input: Buffer, output: Array) => return; } - resolve(output); + try { + resolve(output()); + } catch (ex) { + reject(ex); + } }); }); } \ No newline at end of file