'use strict'; import { Functions, Iterables } from './system'; import { CancellationToken, CodeLens, CodeLensProvider, Command, commands, DocumentSelector, Event, EventEmitter, ExtensionContext, Position, Range, SymbolInformation, SymbolKind, TextDocument, Uri } from 'vscode'; import { Commands, DiffWithPreviousCommandArgs, ShowQuickCommitDetailsCommandArgs, ShowQuickCommitFileDetailsCommandArgs, ShowQuickFileHistoryCommandArgs } from './commands'; import { CodeLensCommand, CodeLensLocations, configuration, ICodeLensConfig, ICodeLensLanguageLocation } from './configuration'; import { BuiltInCommands, DocumentSchemes } from './constants'; import { DocumentTracker, GitDocumentState } from './trackers/documentTracker'; import { GitBlame, GitBlameCommit, GitBlameLines, GitService, GitUri } from './gitService'; import { Logger } from './logger'; export class GitRecentChangeCodeLens extends CodeLens { constructor( public readonly symbolKind: SymbolKind, public readonly uri: GitUri | undefined, private readonly blame: (() => GitBlameLines | undefined) | undefined, public readonly blameRange: Range, public readonly isFullRange: boolean, range: Range, public readonly desiredCommand: CodeLensCommand, command?: Command | undefined ) { super(range, command); } getBlame(): GitBlameLines | undefined { return this.blame && this.blame(); } } export class GitAuthorsCodeLens extends CodeLens { constructor( public readonly symbolKind: SymbolKind, public readonly uri: GitUri | undefined, private readonly blame: () => GitBlameLines | undefined, public readonly blameRange: Range, public readonly isFullRange: boolean, range: Range, public readonly desiredCommand: CodeLensCommand ) { super(range); } getBlame(): GitBlameLines | undefined { return this.blame(); } } export class GitCodeLensProvider implements CodeLensProvider { private _onDidChangeCodeLenses = new EventEmitter(); public get onDidChangeCodeLenses(): Event { return this._onDidChangeCodeLenses.event; } static selector: DocumentSelector = [{ scheme: DocumentSchemes.File }, { scheme: DocumentSchemes.Git }, { scheme: DocumentSchemes.GitLensGit }]; private _debug: boolean; constructor( context: ExtensionContext, private readonly _git: GitService, private readonly _tracker: DocumentTracker ) { } reset(reason?: 'idle' | 'saved') { this._onDidChangeCodeLenses.fire(); } async provideCodeLenses(document: TextDocument, token: CancellationToken): Promise { const trackedDocument = await this._tracker.getOrAdd(document); if (!trackedDocument.isBlameable) return []; let dirty = false; if (document.isDirty) { // Only allow dirty blames if we are idle if (trackedDocument.isDirtyIdle) { const maxLines = configuration.get(configuration.name('advanced')('blame')('sizeThresholdAfterEdit').value); if (maxLines > 0 && document.lineCount > maxLines) { dirty = true; } } else { dirty = true; } } const cfg = configuration.get(configuration.name('codeLens').value, document.uri); this._debug = cfg.debug; let languageLocations = cfg.perLanguageLocations && cfg.perLanguageLocations.find(ll => ll.language !== undefined && ll.language.toLowerCase() === document.languageId); if (languageLocations == null) { languageLocations = { language: undefined, locations: cfg.locations, customSymbols: cfg.customLocationSymbols } as ICodeLensLanguageLocation; } languageLocations.customSymbols = languageLocations.customSymbols != null ? languageLocations.customSymbols = languageLocations.customSymbols.map(s => s.toLowerCase()) : []; const lenses: CodeLens[] = []; const gitUri = trackedDocument.uri; let blame: GitBlame | undefined; let symbols; if (!dirty) { if (token.isCancellationRequested) return lenses; if (languageLocations.locations.length === 1 && languageLocations.locations.includes(CodeLensLocations.Document)) { blame = document.isDirty ? await this._git.getBlameForFileContents(gitUri, document.getText()) : await this._git.getBlameForFile(gitUri); } else { [blame, symbols] = await Promise.all([ document.isDirty ? this._git.getBlameForFileContents(gitUri, document.getText()) : this._git.getBlameForFile(gitUri), commands.executeCommand(BuiltInCommands.ExecuteDocumentSymbolProvider, document.uri) as Promise ]); } if (blame === undefined || blame.lines.length === 0) return lenses; } else { if (languageLocations.locations.length !== 1 || !languageLocations.locations.includes(CodeLensLocations.Document)) { symbols = await commands.executeCommand(BuiltInCommands.ExecuteDocumentSymbolProvider, document.uri) as SymbolInformation[]; } } if (token.isCancellationRequested) return lenses; const documentRangeFn = Functions.once(() => document.validateRange(new Range(0, 1000000, 1000000, 1000000))); // Since blame information isn't valid when there are unsaved changes -- update the lenses appropriately const dirtyCommand = dirty ? { title: this.getDirtyTitle(cfg) } as Command : undefined; if (symbols !== undefined) { Logger.log('GitCodeLensProvider.provideCodeLenses:', `${symbols.length} symbol(s) found`); symbols.forEach(sym => this.provideCodeLens(lenses, document, sym, languageLocations!, documentRangeFn, blame, gitUri, cfg, dirty, dirtyCommand)); } if ((languageLocations.locations.includes(CodeLensLocations.Document) || languageLocations.customSymbols.includes('file')) && !languageLocations.customSymbols.includes('!file')) { // 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 blameRange = documentRangeFn(); let blameForRangeFn: (() => GitBlameLines | undefined) | undefined = undefined; if (dirty || cfg.recentChange.enabled) { if (!dirty) { blameForRangeFn = Functions.once(() => this._git.getBlameForRangeSync(blame!, gitUri!, blameRange)); } lenses.push(new GitRecentChangeCodeLens( SymbolKind.File, gitUri, blameForRangeFn, blameRange, true, new Range(0, 0, 0, blameRange.start.character), cfg.recentChange.command, dirtyCommand )); } if (!dirty && cfg.authors.enabled) { if (blameForRangeFn === undefined) { blameForRangeFn = Functions.once(() => this._git.getBlameForRangeSync(blame!, gitUri!, blameRange)); } lenses.push(new GitAuthorsCodeLens( SymbolKind.File, gitUri, blameForRangeFn, blameRange, true, new Range(0, 1, 0, blameRange.start.character), cfg.authors.command )); } } } return lenses; } private validateSymbolAndGetBlameRange(symbol: SymbolInformation, languageLocation: ICodeLensLanguageLocation, documentRangeFn: () => Range): Range | undefined { let valid = false; let range: Range | undefined; const symbolName = SymbolKind[symbol.kind].toLowerCase(); switch (symbol.kind) { case SymbolKind.File: if (languageLocation.locations.includes(CodeLensLocations.Containers) || languageLocation.customSymbols!.includes(symbolName)) { valid = !languageLocation.customSymbols!.includes(`!${symbolName}`); } if (valid) { // Adjust the range to be for the whole file range = documentRangeFn(); } break; case SymbolKind.Package: if (languageLocation.locations.includes(CodeLensLocations.Containers) || languageLocation.customSymbols!.includes(symbolName)) { valid = !languageLocation.customSymbols!.includes(`!${symbolName}`); } if (valid) { // Adjust the range to be for the whole file if (symbol.location.range.start.line === 0 && symbol.location.range.end.line === 0) { range = documentRangeFn(); } } break; case SymbolKind.Class: case SymbolKind.Interface: case SymbolKind.Module: case SymbolKind.Namespace: case SymbolKind.Struct: if (languageLocation.locations.includes(CodeLensLocations.Containers) || languageLocation.customSymbols!.includes(symbolName)) { valid = !languageLocation.customSymbols!.includes(`!${symbolName}`); } break; case SymbolKind.Constructor: case SymbolKind.Enum: case SymbolKind.Function: case SymbolKind.Method: if (languageLocation.locations.includes(CodeLensLocations.Blocks) || languageLocation.customSymbols!.includes(symbolName)) { valid = !languageLocation.customSymbols!.includes(`!${symbolName}`); } break; default: if (languageLocation.customSymbols!.includes(symbolName)) { valid = !languageLocation.customSymbols!.includes(`!${symbolName}`); } break; } return valid ? range || symbol.location.range : undefined; } private provideCodeLens(lenses: CodeLens[], document: TextDocument, symbol: SymbolInformation, languageLocation: ICodeLensLanguageLocation, documentRangeFn: () => Range, blame: GitBlame | undefined, gitUri: GitUri | undefined, cfg: ICodeLensConfig, dirty: boolean, dirtyCommand: Command | undefined): void { const blameRange = this.validateSymbolAndGetBlameRange(symbol, languageLocation, documentRangeFn); if (blameRange === undefined) return; const line = document.lineAt(symbol.location.range.start); // Make sure there is only 1 lens per line if (lenses.length && lenses[lenses.length - 1].range.start.line === line.lineNumber) return; // Anchor the code lens to the start of the line -- so that the range won't change with edits (otherwise the code lens will be removed and re-added) let startChar = 0; let blameForRangeFn: (() => GitBlameLines | undefined) | undefined; if (dirty || cfg.recentChange.enabled) { if (!dirty) { blameForRangeFn = Functions.once(() => this._git.getBlameForRangeSync(blame!, gitUri!, blameRange)); } lenses.push(new GitRecentChangeCodeLens(symbol.kind, gitUri, blameForRangeFn, blameRange, false, line.range.with(new Position(line.range.start.line, startChar)), cfg.recentChange.command, dirtyCommand)); startChar++; } if (cfg.authors.enabled) { let multiline = !blameRange.isSingleLine; // HACK for Omnisharp, since it doesn't return full ranges if (!multiline && document.languageId === 'csharp') { switch (symbol.kind) { case SymbolKind.File: break; case SymbolKind.Package: case SymbolKind.Module: case SymbolKind.Namespace: case SymbolKind.Class: case SymbolKind.Interface: case SymbolKind.Constructor: case SymbolKind.Method: case SymbolKind.Function: case SymbolKind.Enum: multiline = true; break; } } if (multiline && !dirty) { if (blameForRangeFn === undefined) { blameForRangeFn = Functions.once(() => this._git.getBlameForRangeSync(blame!, gitUri!, blameRange)); } lenses.push(new GitAuthorsCodeLens(symbol.kind, gitUri, blameForRangeFn, blameRange, false, line.range.with(new Position(line.range.start.line, startChar)), cfg.authors.command)); } } } resolveCodeLens(lens: CodeLens, token: CancellationToken): CodeLens | Thenable { if (lens instanceof GitRecentChangeCodeLens) return this.resolveGitRecentChangeCodeLens(lens, token); if (lens instanceof GitAuthorsCodeLens) return this.resolveGitAuthorsCodeLens(lens, token); return Promise.reject(undefined); } private resolveGitRecentChangeCodeLens(lens: GitRecentChangeCodeLens, token: CancellationToken): CodeLens { const blame = lens.getBlame(); if (blame === undefined) return lens; const recentCommit = Iterables.first(blame.commits.values()); let title = `${recentCommit.author}, ${recentCommit.formattedDate}`; if (this._debug) { title += ` [${SymbolKind[lens.symbolKind]}(${lens.range.start.character}-${lens.range.end.character}), Lines (${lens.blameRange.start.line + 1}-${lens.blameRange.end.line + 1}), Commit (${recentCommit.shortSha})]`; } switch (lens.desiredCommand) { case CodeLensCommand.DiffWithPrevious: return this.applyDiffWithPreviousCommand(title, lens, blame, recentCommit); case CodeLensCommand.ShowQuickCommitDetails: return this.applyShowQuickCommitDetailsCommand(title, lens, blame, recentCommit); case CodeLensCommand.ShowQuickCommitFileDetails: return this.applyShowQuickCommitFileDetailsCommand(title, lens, blame, recentCommit); case CodeLensCommand.ShowQuickCurrentBranchHistory: return this.applyShowQuickCurrentBranchHistoryCommand(title, lens, blame, recentCommit); case CodeLensCommand.ShowQuickFileHistory: return this.applyShowQuickFileHistoryCommand(title, lens, blame, recentCommit); case CodeLensCommand.ToggleFileBlame: return this.applyToggleFileBlameCommand(title, lens, blame); default: return lens; } } private resolveGitAuthorsCodeLens(lens: GitAuthorsCodeLens, token: CancellationToken): CodeLens { const blame = lens.getBlame(); if (blame === undefined) return lens; const count = blame.authors.size; let title = `${count} ${count > 1 ? 'authors' : 'author'} (${Iterables.first(blame.authors.values()).name}${count > 1 ? ' and others' : ''})`; if (this._debug) { title += ` [${SymbolKind[lens.symbolKind]}(${lens.range.start.character}-${lens.range.end.character}), Lines (${lens.blameRange.start.line + 1}-${lens.blameRange.end.line + 1}), Authors (${Iterables.join(Iterables.map(blame.authors.values(), a => a.name), ', ')})]`; } switch (lens.desiredCommand) { case CodeLensCommand.DiffWithPrevious: return this.applyDiffWithPreviousCommand(title, lens, blame); case CodeLensCommand.ShowQuickCommitDetails: return this.applyShowQuickCommitDetailsCommand(title, lens, blame); case CodeLensCommand.ShowQuickCommitFileDetails: return this.applyShowQuickCommitFileDetailsCommand(title, lens, blame); case CodeLensCommand.ShowQuickCurrentBranchHistory: return this.applyShowQuickCurrentBranchHistoryCommand(title, lens, blame); case CodeLensCommand.ShowQuickFileHistory: return this.applyShowQuickFileHistoryCommand(title, lens, blame); case CodeLensCommand.ToggleFileBlame: return this.applyToggleFileBlameCommand(title, lens, blame); default: return lens; } } private applyDiffWithPreviousCommand(title: string, lens: T, blame: GitBlameLines, commit?: GitBlameCommit): T { if (commit === undefined) { const blameLine = blame.allLines[lens.range.start.line]; commit = blame.commits.get(blameLine.sha); } lens.command = { title: title, command: Commands.DiffWithPrevious, arguments: [ Uri.file(lens.uri!.fsPath), { commit: commit } as DiffWithPreviousCommandArgs ] }; return lens; } private applyShowQuickCommitDetailsCommand(title: string, lens: T, blame: GitBlameLines, commit?: GitBlameCommit): T { lens.command = { title: title, command: commit !== undefined && commit.isUncommitted ? '' : CodeLensCommand.ShowQuickCommitDetails, arguments: [ Uri.file(lens.uri!.fsPath), { commit, sha: commit === undefined ? undefined : commit.sha } as ShowQuickCommitDetailsCommandArgs] }; return lens; } private applyShowQuickCommitFileDetailsCommand(title: string, lens: T, blame: GitBlameLines, commit?: GitBlameCommit): T { lens.command = { title: title, command: commit !== undefined && commit.isUncommitted ? '' : CodeLensCommand.ShowQuickCommitFileDetails, arguments: [ Uri.file(lens.uri!.fsPath), { commit, sha: commit === undefined ? undefined : commit.sha } as ShowQuickCommitFileDetailsCommandArgs] }; return lens; } private applyShowQuickCurrentBranchHistoryCommand(title: string, lens: T, blame: GitBlameLines, commit?: GitBlameCommit): T { lens.command = { title: title, command: CodeLensCommand.ShowQuickCurrentBranchHistory, arguments: [Uri.file(lens.uri!.fsPath)] }; return lens; } private applyShowQuickFileHistoryCommand(title: string, lens: T, blame: GitBlameLines, commit?: GitBlameCommit): T { lens.command = { title: title, command: CodeLensCommand.ShowQuickFileHistory, arguments: [ Uri.file(lens.uri!.fsPath), { range: lens.isFullRange ? undefined : lens.blameRange } as ShowQuickFileHistoryCommandArgs ] }; return lens; } private applyToggleFileBlameCommand(title: string, lens: T, blame: GitBlameLines): T { lens.command = { title: title, command: Commands.ToggleFileBlame, arguments: [Uri.file(lens.uri!.fsPath)] }; return lens; } private getDirtyTitle(cfg: ICodeLensConfig) { if (cfg.recentChange.enabled && cfg.authors.enabled) { return configuration.get(configuration.name('strings')('codeLens')('unsavedChanges')('recentChangeAndAuthors').value); } else if (cfg.recentChange.enabled) { return configuration.get(configuration.name('strings')('codeLens')('unsavedChanges')('recentChangeOnly').value); } else { return configuration.get(configuration.name('strings')('codeLens')('unsavedChanges')('authorsOnly').value); } } }