diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index d373c6a..7a8bd27 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -1,20 +1,16 @@ 'use strict'; import {CancellationToken, CodeLens, CodeLensProvider, commands, ExtensionContext, Location, Position, Range, SymbolInformation, SymbolKind, TextDocument, Uri} from 'vscode'; import {Commands, VsCodeCommands, WorkspaceState} from './constants'; -import GitBlameProvider, {IGitBlame, IGitBlameCommit} from './gitBlameProvider'; +import GitProvider, {IGitBlame, IGitBlameCommit} from './gitProvider'; import * as moment from 'moment'; export class GitBlameCodeLens extends CodeLens { - constructor(private blameProvider: GitBlameProvider, public fileName: string, public blameRange: Range, range: Range) { + constructor(private git: GitProvider, public fileName: string, public blameRange: Range, range: Range) { super(range); } getBlame(): Promise { - return this.blameProvider.getBlameForRange(this.fileName, this.blameProvider.repoPath, this.blameRange); - } - - static toUri(lens: GitBlameCodeLens, repoPath: string, commit: IGitBlameCommit, index: number, commitCount: number): Uri { - return GitBlameProvider.toBlameUri(repoPath, commit, lens.blameRange, index, commitCount); + return this.git.getBlameForRange(this.fileName, this.blameRange); } } @@ -22,17 +18,13 @@ export class GitHistoryCodeLens extends CodeLens { constructor(public repoPath: string, public fileName: string, range: Range) { super(range); } - - // static toUri(lens: GitHistoryCodeLens, index: number): Uri { - // return GitBlameProvider.toBlameUri(Object.assign({ repoPath: lens.repoPath, index: index, range: lens.blameRange, lines: lines }, line)); - // } } export default class GitCodeLensProvider implements CodeLensProvider { - constructor(context: ExtensionContext, public blameProvider: GitBlameProvider) { } + constructor(context: ExtensionContext, private git: GitProvider) { } provideCodeLenses(document: TextDocument, token: CancellationToken): CodeLens[] | Thenable { - this.blameProvider.blameFile(document.fileName, this.blameProvider.repoPath); + this.git.getBlameForFile(document.fileName); return (commands.executeCommand(VsCodeCommands.ExecuteDocumentSymbolProvider, document.uri) as Promise).then(symbols => { let lenses: CodeLens[] = []; @@ -41,8 +33,8 @@ export default class GitCodeLensProvider implements CodeLensProvider { // 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(this.blameProvider, document.fileName, docRange, new Range(0, 0, 0, docRange.start.character))); - lenses.push(new GitHistoryCodeLens(this.blameProvider.repoPath, document.fileName, new Range(0, 1, 0, docRange.start.character))); + lenses.push(new GitBlameCodeLens(this.git, document.fileName, docRange, new Range(0, 0, 0, docRange.start.character))); + lenses.push(new GitHistoryCodeLens(this.git.repoPath, document.fileName, new Range(0, 1, 0, docRange.start.character))); } return lenses; }); @@ -74,8 +66,8 @@ export default class GitCodeLensProvider implements CodeLensProvider { startChar += Math.floor(symbol.name.length / 2); } - lenses.push(new GitBlameCodeLens(this.blameProvider, document.fileName, symbol.location.range, line.range.with(new Position(line.range.start.line, startChar)))); - lenses.push(new GitHistoryCodeLens(this.blameProvider.repoPath, document.fileName, line.range.with(new Position(line.range.start.line, startChar + 1)))); + lenses.push(new GitBlameCodeLens(this.git, document.fileName, symbol.location.range, line.range.with(new Position(line.range.start.line, startChar)))); + lenses.push(new GitHistoryCodeLens(this.git.repoPath, document.fileName, line.range.with(new Position(line.range.start.line, startChar + 1)))); } resolveCodeLens(lens: CodeLens, token: CancellationToken): Thenable { diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..5f57868 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,44 @@ +'use strict' +import {commands, Disposable, Position, Range, Uri, window} from 'vscode'; +import {Commands, VsCodeCommands} from './constants'; +import GitProvider from './gitProvider'; + +abstract class Command extends Disposable { + private _subscriptions: Disposable; + + constructor(command: Commands) { + super(() => this.dispose()); + this._subscriptions = commands.registerCommand(command, this.execute.bind(this)); + } + + dispose() { + this._subscriptions && this._subscriptions.dispose(); + super.dispose(); + } + + abstract execute(...args): any; +} + +export class BlameCommand extends Command { + constructor(private git: GitProvider) { + 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; + } + + return this.git.getBlameLocations(uri.path, range).then(locations => { + return commands.executeCommand(VsCodeCommands.ShowReferences, uri, position, locations); + }); + } +} \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts index d769562..9c02d87 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,5 @@ +'use strict' + export type WorkspaceState = 'repoPath'; export const WorkspaceState = { RepoPath: 'repoPath' as WorkspaceState diff --git a/src/contentProvider.ts b/src/contentProvider.ts index fa75512..1248d2c 100644 --- a/src/contentProvider.ts +++ b/src/contentProvider.ts @@ -1,8 +1,7 @@ 'use strict'; import {Disposable, EventEmitter, ExtensionContext, OverviewRulerLane, Range, TextEditor, TextEditorDecorationType, TextDocumentContentProvider, Uri, window, workspace} from 'vscode'; import {DocumentSchemes, WorkspaceState} from './constants'; -import {gitGetVersionText} from './git'; -import GitBlameProvider, {IGitBlameUriData} from './gitBlameProvider'; +import GitProvider, {IGitBlameUriData} from './gitProvider'; import * as moment from 'moment'; export default class GitBlameContentProvider implements TextDocumentContentProvider { @@ -12,7 +11,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi private _onDidChange = new EventEmitter(); //private _subscriptions: Disposable; - constructor(context: ExtensionContext, public blameProvider: GitBlameProvider) { + constructor(context: ExtensionContext, private git: GitProvider) { this._blameDecoration = window.createTextEditorDecorationType({ dark: { backgroundColor: 'rgba(255, 255, 255, 0.15)', @@ -48,11 +47,11 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi } provideTextDocumentContent(uri: Uri): string | Thenable { - const data = GitBlameProvider.fromBlameUri(uri); + const data = this.git.fromBlameUri(uri); //const editor = this._findEditor(Uri.file(join(data.repoPath, data.file))); - return gitGetVersionText(data.originalFileName || data.fileName, this.blameProvider.repoPath, data.sha).then(text => { + return this.git.getVersionedFileText(data.originalFileName || data.fileName, data.sha).then(text => { this.update(uri); // TODO: This only works on the first load -- not after since it is cached @@ -64,7 +63,7 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi return text; }); - // return gitGetVersionFile(data.file, this.repoPath, data.sha).then(dst => { + // return this.git.getVersionedFile(data.fileName, data.sha).then(dst => { // let uri = Uri.parse(`file:${dst}`) // return workspace.openTextDocument(uri).then(doc => { // this.update(uri); @@ -87,19 +86,21 @@ export default class GitBlameContentProvider implements TextDocumentContentProvi // 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) { - clearInterval(handle); - this.blameProvider.getBlameForShaRange(data.fileName, this.blameProvider.repoPath, data.sha, data.range).then(blame => { - if (blame.lines.length) { - editor.setDecorations(this._blameDecoration, blame.lines.map(l => { - return { - range: editor.document.validateRange(new Range(l.originalLine, 0, l.originalLine, 1000000)), - hoverMessage: `${moment(blame.commit.date).format('MMMM Do, YYYY hh:MMa')}\n${blame.commit.author}\n${l.sha}` - }; - })); - } - }); - } + if (!editor) return; + + clearInterval(handle); + this.git.getBlameForShaRange(data.fileName, data.sha, data.range).then(blame => { + if (!blame.lines.length) return; + + this.git.getCommitMessage(data.sha).then(msg => { + editor.setDecorations(this._blameDecoration, blame.lines.map(l => { + return { + range: editor.document.validateRange(new Range(l.originalLine, 0, l.originalLine, 1000000)), + hoverMessage: `${msg}\n${blame.commit.author}\n${moment(blame.commit.date).format('MMMM Do, YYYY hh:MM a')}\n${l.sha}` + }; + })); + }) + }); }, 200); } diff --git a/src/extension.ts b/src/extension.ts index 4f0752b..4a42fa6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,10 +1,10 @@ 'use strict'; -import {CodeLens, commands, DocumentSelector, ExtensionContext, languages, Position, Range, Uri, window, workspace} from 'vscode'; +import {CodeLens, DocumentSelector, ExtensionContext, languages, workspace} from 'vscode'; import GitCodeLensProvider, {GitBlameCodeLens} from './codeLensProvider'; import GitContentProvider from './contentProvider'; -import {gitRepoPath} from './git'; -import GitBlameProvider from './gitBlameProvider'; -import {Commands, VsCodeCommands, WorkspaceState} from './constants'; +import GitProvider from './gitProvider'; +import {BlameCommand} from './commands'; +import {WorkspaceState} from './constants'; // this method is called when your extension is activated export function activate(context: ExtensionContext) { @@ -16,35 +16,19 @@ export function activate(context: ExtensionContext) { } console.log(`GitLens active: ${workspace.rootPath}`); - gitRepoPath(workspace.rootPath).then(repoPath => { + + const git = new GitProvider(context); + context.subscriptions.push(git); + + git.getRepoPath(workspace.rootPath).then(repoPath => { context.workspaceState.update(WorkspaceState.RepoPath, repoPath); - const blameProvider = new GitBlameProvider(context); - context.subscriptions.push(blameProvider); + context.subscriptions.push(workspace.registerTextDocumentContentProvider(GitContentProvider.scheme, new GitContentProvider(context, git))); - context.subscriptions.push(workspace.registerTextDocumentContentProvider(GitContentProvider.scheme, new GitContentProvider(context, blameProvider))); - - context.subscriptions.push(commands.registerCommand(Commands.ShowBlameHistory, (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; - } - - console.log(uri.path, blameProvider.repoPath, range, position); - return blameProvider.getBlameLocations(uri.path, blameProvider.repoPath, range).then(locations => { - return commands.executeCommand(VsCodeCommands.ShowReferences, uri, position, locations); - }); - })); + context.subscriptions.push(new BlameCommand(git)); const selector: DocumentSelector = { scheme: 'file' }; - context.subscriptions.push(languages.registerCodeLensProvider(selector, new GitCodeLensProvider(context, blameProvider))); + context.subscriptions.push(languages.registerCodeLensProvider(selector, new GitCodeLensProvider(context, git))); }).catch(reason => console.warn(reason)); } diff --git a/src/git.ts b/src/git.ts index 7eeb809..c9f89f9 100644 --- a/src/git.ts +++ b/src/git.ts @@ -4,59 +4,70 @@ import * as fs from 'fs'; import * as tmp from 'tmp'; import {spawnPromise} from 'spawn-rx'; -export function gitNormalizePath(fileName: string, repoPath: string) { - fileName = fileName.replace(/\\/g, '/'); - return isAbsolute(fileName) ? relative(repoPath, fileName) : fileName; +function gitCommand(cwd: string, ...args) { + return spawnPromise('git', args, { cwd: cwd }); } -export function gitRepoPath(cwd) { - return gitCommand(cwd, 'rev-parse', '--show-toplevel').then(data => data.replace(/\r?\n|\r/g, '')); -} +export default class Git { + static normalizePath(fileName: string, repoPath: string) { + fileName = fileName.replace(/\\/g, '/'); + return isAbsolute(fileName) ? relative(repoPath, fileName) : fileName; + } -export function gitBlame(fileName: string, repoPath: string) { - fileName = gitNormalizePath(fileName, repoPath); + static repoPath(cwd: string) { + return gitCommand(cwd, 'rev-parse', '--show-toplevel').then(data => data.replace(/\r?\n|\r/g, '')); + } - console.log('git', 'blame', '-fnw', '--root', '--', fileName); - return gitCommand(repoPath, 'blame', '-fnw', '--root', '--', fileName); - // .then(s => { console.log(s); return s; }) - // .catch(ex => console.error(ex)); -} + static blame(fileName: string, repoPath: string) { + fileName = Git.normalizePath(fileName, repoPath); -export function gitGetVersionFile(fileName: string, repoPath: string, sha: string) { - return new Promise((resolve, reject) => { - gitGetVersionText(fileName, repoPath, sha).then(data => { - let ext = extname(fileName); - tmp.file({ prefix: `${basename(fileName, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { - if (err) { - reject(err); - return; - } + console.log('git', 'blame', '-fnw', '--root', '--', fileName); + return gitCommand(repoPath, 'blame', '-fnw', '--root', '--', fileName); + // .then(s => { console.log(s); return s; }) + // .catch(ex => console.error(ex)); + } - console.log("File: ", destination); - console.log("Filedescriptor: ", fd); - - fs.appendFile(destination, data, err => { + static getVersionedFile(fileName: string, repoPath: string, sha: string) { + return new Promise((resolve, reject) => { + Git.getVersionedFileText(fileName, repoPath, sha).then(data => { + let ext = extname(fileName); + tmp.file({ prefix: `${basename(fileName, ext)}-${sha}_`, postfix: ext }, (err, destination, fd, cleanupCallback) => { if (err) { reject(err); return; } - resolve(destination); + + console.log("File: ", destination); + console.log("Filedescriptor: ", fd); + + fs.appendFile(destination, data, err => { + if (err) { + reject(err); + return; + } + resolve(destination); + }); }); }); }); - }); -} + } -export function gitGetVersionText(fileName: string, repoPath: string, sha: string) { - fileName = gitNormalizePath(fileName, repoPath); - sha = sha.replace('^', ''); + static getVersionedFileText(fileName: string, repoPath: string, sha: string) { + fileName = Git.normalizePath(fileName, repoPath); + sha = sha.replace('^', ''); - console.log('git', 'show', `${sha}:${fileName}`); - return gitCommand(repoPath, 'show', `${sha}:${fileName}`); - // .then(s => { console.log(s); return s; }) - // .catch(ex => console.error(ex)); -} + console.log('git', 'show', `${sha}:${fileName}`); + return gitCommand(repoPath, 'show', `${sha}:${fileName}`); + // .then(s => { console.log(s); return s; }) + // .catch(ex => console.error(ex)); + } -function gitCommand(cwd: string, ...args) { - return spawnPromise('git', args, { cwd: cwd }); + static getCommitMessage(sha: string, repoPath: string) { + sha = sha.replace('^', ''); + + console.log('git', 'show', '-s', '--format=%B', sha); + return gitCommand(repoPath, 'show', '-s', '--format=%B', sha); + // .then(s => { console.log(s); return s; }) + // .catch(ex => console.error(ex)); + } } \ No newline at end of file diff --git a/src/gitBlameProvider.ts b/src/gitProvider.ts similarity index 68% rename from src/gitBlameProvider.ts rename to src/gitProvider.ts index 4f85995..9e2ca57 100644 --- a/src/gitBlameProvider.ts +++ b/src/gitProvider.ts @@ -1,41 +1,54 @@ +'use strict' import {Disposable, ExtensionContext, Location, Position, Range, Uri, workspace} from 'vscode'; import {DocumentSchemes, WorkspaceState} from './constants'; -import {gitBlame, gitNormalizePath} from './git'; +import Git from './git'; import {basename, dirname, extname} from 'path'; 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; -export default class GitBlameProvider extends Disposable { +export default class GitProvider extends Disposable { public repoPath: string; - private _files: Map>; - private _subscriptions: Disposable; + private _blames: Map>; + private _subscription: Disposable; constructor(context: ExtensionContext) { super(() => this.dispose()); this.repoPath = context.workspaceState.get(WorkspaceState.RepoPath) as string; - this._files = new Map(); - this._subscriptions = Disposable.from(workspace.onDidCloseTextDocument(d => this._removeFile(d.fileName)), - workspace.onDidChangeTextDocument(e => this._removeFile(e.document.fileName))); + this._blames = new Map(); + this._subscription = Disposable.from(workspace.onDidCloseTextDocument(d => this._removeFile(d.fileName)), + workspace.onDidChangeTextDocument(e => this._removeFile(e.document.fileName))); } dispose() { - this._files.clear(); - this._subscriptions && this._subscriptions.dispose(); + this._blames.clear(); + this._subscription && this._subscription.dispose(); super.dispose(); } - blameFile(fileName: string, repoPath: string) { - fileName = gitNormalizePath(fileName, repoPath); + private _removeFile(fileName: string) { + this._blames.delete(fileName); + } - let blame = this._files.get(fileName); + getRepoPath(cwd: string) { + return Git.repoPath(cwd); + } + + getCommitMessage(sha: string) { + return Git.getCommitMessage(sha, this.repoPath); + } + + getBlameForFile(fileName: string) { + fileName = Git.normalizePath(fileName, this.repoPath); + + let blame = this._blames.get(fileName); if (blame !== undefined) return blame; - blame = gitBlame(fileName, repoPath) + blame = Git.blame(fileName, this.repoPath) .then(data => { const commits: Map = new Map(); const lines: Array = []; @@ -70,12 +83,12 @@ export default class GitBlameProvider extends Disposable { return { commits, lines }; }); - this._files.set(fileName, blame); + this._blames.set(fileName, blame); return blame; } - getBlameForRange(fileName: string, repoPath: string, range: Range): Promise { - return this.blameFile(fileName, repoPath).then(blame => { + getBlameForRange(fileName: string, range: Range): Promise { + return this.getBlameForFile(fileName).then(blame => { if (!blame.lines.length) return blame; const lines = blame.lines.slice(range.start.line, range.end.line + 1); @@ -86,8 +99,8 @@ export default class GitBlameProvider extends Disposable { }); } - getBlameForShaRange(fileName: string, repoPath: string, sha: string, range: Range): Promise<{commit: IGitBlameCommit, lines: IGitBlameLine[]}> { - return this.blameFile(fileName, repoPath).then(blame => { + getBlameForShaRange(fileName: string, sha: string, range: Range): Promise<{commit: IGitBlameCommit, lines: IGitBlameLine[]}> { + return this.getBlameForFile(fileName).then(blame => { return { commit: blame.commits.get(sha), lines: blame.lines.slice(range.start.line, range.end.line + 1).filter(l => l.sha === sha) @@ -95,19 +108,19 @@ export default class GitBlameProvider extends Disposable { }); } - getBlameLocations(fileName: string, repoPath: string, range: Range) { - return this.getBlameForRange(fileName, repoPath, range).then(blame => { + getBlameLocations(fileName: string, range: Range) { + return this.getBlameForRange(fileName, range).then(blame => { const commitCount = blame.commits.size; const locations: Array = []; Array.from(blame.commits.values()) .sort((a, b) => b.date.getTime() - a.date.getTime()) .forEach((c, i) => { - const uri = GitBlameProvider.toBlameUri(this.repoPath, c, range, i + 1, commitCount); + const uri = this.toBlameUri(c, range, i + 1, commitCount); blame.lines .filter(l => l.sha === c.sha) .forEach(l => locations.push(new Location(l.originalFileName - ? GitBlameProvider.toBlameUri(this.repoPath, c, range, i + 1, commitCount, l.originalFileName) + ? this.toBlameUri(c, range, i + 1, commitCount, l.originalFileName) : uri, new Position(l.originalLine, 0)))); }); @@ -116,11 +129,15 @@ export default class GitBlameProvider extends Disposable { }); } - private _removeFile(fileName: string) { - this._files.delete(fileName); + getVersionedFile(fileName: string, sha: string) { + return Git.getVersionedFile(fileName, this.repoPath, sha); } - static toBlameUri(repoPath: string, commit: IGitBlameCommit, range: Range, index: number, commitCount: number, originalFileName?: string) { + getVersionedFileText(fileName: string, sha: string) { + return Git.getVersionedFileText(fileName, this.repoPath, sha); + } + + toBlameUri(commit: IGitBlameCommit, range: Range, index: number, commitCount: number, originalFileName?: string) { const pad = n => ("0000000" + n).slice(-("" + commitCount).length); const fileName = originalFileName || commit.fileName; @@ -131,10 +148,10 @@ export default class GitBlameProvider extends Disposable { data.originalFileName = originalFileName; } // NOTE: 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(index)}. ${commit.author}, ${moment(commit.date).format('MMM D, YYYY hh:MMa')} - ${path}?${JSON.stringify(data)}`); + return Uri.parse(`${DocumentSchemes.GitBlame}:${pad(index)}. ${commit.author}, ${moment(commit.date).format('MMM D, YYYY hh:MM a')} - ${path}?${JSON.stringify(data)}`); } - static fromBlameUri(uri: Uri): IGitBlameUriData { + fromBlameUri(uri: Uri) { const 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;