Git - experimental git blame editor decoration (#234209)

* Initial implementation

* Add setting, and cache blame information more aggressively
This commit is contained in:
Ladislau Szomoru 2024-11-19 22:05:19 +01:00 коммит произвёл GitHub
Родитель e7ee6c08df
Коммит 80635b487b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
8 изменённых файлов: 531 добавлений и 4 удалений

Просмотреть файл

@ -3188,6 +3188,12 @@
"maximum": 100,
"markdownDescription": "%config.similarityThreshold%",
"scope": "resource"
},
"git.blame.editorDecoration.enabled": {
"type": "boolean",
"default": false,
"markdownDescription": "%config.blameEditorDecoration.enabled%",
"scope": "resource"
}
}
},
@ -3291,6 +3297,16 @@
"highContrast": "#8db9e2",
"highContrastLight": "#1258a7"
}
},
{
"id": "git.blame.editorDecorationForeground",
"description": "%colors.blameEditorDecoration%",
"defaults": {
"dark": "editorCodeLens.foreground",
"light": "editorCodeLens.foreground",
"highContrast": "editorCodeLens.foreground",
"highContrastLight": "editorCodeLens.foreground"
}
}
],
"configurationDefaults": {

Просмотреть файл

@ -276,6 +276,7 @@
"config.publishBeforeContinueOn.never": "Never publish unpublished Git state when using Continue Working On from a Git repository",
"config.publishBeforeContinueOn.prompt": "Prompt to publish unpublished Git state when using Continue Working On from a Git repository",
"config.similarityThreshold": "Controls the threshold of the similarity index (the amount of additions/deletions compared to the file's size) for changes in a pair of added/deleted files to be considered a rename. **Note:** Requires Git version `2.18.0` or later.",
"config.blameEditorDecoration.enabled": "Controls whether to show git blame information in the editor using editor decorations",
"submenu.explorer": "Git",
"submenu.commit": "Commit",
"submenu.commit.amend": "Amend",
@ -300,6 +301,7 @@
"colors.incomingDeleted": "Color for deleted incoming resource.",
"colors.incomingRenamed": "Color for renamed incoming resource.",
"colors.incomingModified": "Color for modified incoming resource.",
"colors.blameEditorDecoration": "Color for the blame editor decoration.",
"view.workbench.scm.missing.windows": {
"message": "[Download Git for Windows](https://git-scm.com/download/win)\nAfter installing, please [reload](command:workbench.action.reloadWindow) (or [troubleshoot](command:git.showOutput)). Additional source control providers can be installed [from the Marketplace](command:workbench.extensions.search?%22%40category%3A%5C%22scm%20providers%5C%22%22).",
"comment": [

227
extensions/git/src/blame.ts Normal file
Просмотреть файл

@ -0,0 +1,227 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ConfigurationChangeEvent, DecorationOptions, l10n, Position, Range, TextDocument, TextEditor, TextEditorDecorationType, TextEditorDiff, TextEditorDiffKind, ThemeColor, Uri, window, workspace } from 'vscode';
import { Model } from './model';
import { dispose, fromNow, IDisposable } from './util';
import { Repository } from './repository';
import { throttle } from './decorators';
import { BlameInformation } from './git';
function isLineChanged(lineNumber: number, changes: readonly TextEditorDiff[]): boolean {
for (const change of changes) {
// If the change is a delete, skip it
if (change.kind === TextEditorDiffKind.Deletion) {
continue;
}
const startLineNumber = change.modifiedStartLineNumber;
const endLineNumber = change.modifiedEndLineNumber || startLineNumber;
if (lineNumber >= startLineNumber && lineNumber <= endLineNumber) {
return true;
}
}
return false;
}
function mapLineNumber(lineNumber: number, changes: readonly TextEditorDiff[]): number {
if (changes.length === 0) {
return lineNumber;
}
for (const change of changes) {
// Line number is before the change
if ((change.kind === TextEditorDiffKind.Addition && lineNumber < change.modifiedStartLineNumber) ||
(change.kind === TextEditorDiffKind.Modification && lineNumber < change.modifiedStartLineNumber) ||
(change.kind === TextEditorDiffKind.Deletion && lineNumber < change.originalStartLineNumber)) {
break;
}
// Update line number
switch (change.kind) {
case TextEditorDiffKind.Addition:
lineNumber = lineNumber - (change.modifiedEndLineNumber - change.originalStartLineNumber);
break;
case TextEditorDiffKind.Modification:
if (change.originalStartLineNumber !== change.modifiedStartLineNumber || change.originalEndLineNumber !== change.modifiedEndLineNumber) {
lineNumber = lineNumber - (change.modifiedEndLineNumber - change.originalEndLineNumber);
}
break;
case TextEditorDiffKind.Deletion:
lineNumber = lineNumber + (change.originalEndLineNumber - change.originalStartLineNumber) + 1;
break;
}
}
return lineNumber;
}
interface RepositoryBlameInformation {
readonly commit: string;
readonly blameInformation: Map<Uri, BlameInformation[]>;
}
export class GitBlameController {
private readonly _decorationType: TextEditorDecorationType;
private readonly _blameInformation = new Map<Repository, RepositoryBlameInformation>();
private _repositoryDisposables = new Map<Repository, IDisposable[]>();
private _disposables: IDisposable[] = [];
constructor(private readonly _model: Model) {
this._decorationType = window.createTextEditorDecorationType({
isWholeLine: true,
after: {
color: new ThemeColor('git.blame.editorDecorationForeground')
}
});
this._disposables.push(this._decorationType);
this._model.onDidOpenRepository(this._onDidOpenRepository, this, this._disposables);
this._model.onDidCloseRepository(this._onDidCloseRepository, this, this._disposables);
workspace.onDidChangeConfiguration(this._onDidChangeConfiguration, this, this._disposables);
window.onDidChangeTextEditorSelection(e => this._updateDecorations(e.textEditor), this, this._disposables);
window.onDidChangeTextEditorDiffInformation(e => this._updateDecorations(e.textEditor), this, this._disposables);
this._updateDecorations(window.activeTextEditor);
}
private _onDidChangeConfiguration(e: ConfigurationChangeEvent): void {
if (!e.affectsConfiguration('git.blame.editorDecoration.enabled')) {
return;
}
for (const textEditor of window.visibleTextEditors) {
this._updateDecorations(textEditor);
}
}
private _onDidOpenRepository(repository: Repository): void {
const disposables: IDisposable[] = [];
repository.onDidRunGitStatus(() => {
if (this._blameInformation.get(repository)?.commit === repository.HEAD?.commit) {
return;
}
this._blameInformation.delete(repository);
for (const textEditor of window.visibleTextEditors) {
this._updateDecorations(textEditor);
}
}, this, disposables);
this._repositoryDisposables.set(repository, disposables);
}
private _onDidCloseRepository(repository: Repository): void {
const disposables = this._repositoryDisposables.get(repository);
if (disposables) {
dispose(disposables);
}
this._repositoryDisposables.delete(repository);
this._blameInformation.delete(repository);
}
@throttle
private async _updateDecorations(textEditor: TextEditor | undefined): Promise<void> {
if (!textEditor) {
return;
}
const enabled = workspace.getConfiguration('git').get<boolean>('blame.editorDecoration.enabled', false);
if (!enabled) {
textEditor.setDecorations(this._decorationType, []);
return;
}
const diffInformation = textEditor.diffInformation;
if (!diffInformation || diffInformation.isStale) {
textEditor.setDecorations(this._decorationType, []);
return;
}
const blameInformationCollection = await this._getBlameInformation(textEditor.document);
if (!blameInformationCollection) {
textEditor.setDecorations(this._decorationType, []);
return;
}
const decorations: DecorationOptions[] = [];
for (const lineNumber of textEditor.selections.map(s => s.active.line)) {
// Check if the line is in an add/edit change
if (isLineChanged(lineNumber + 1, diffInformation.diff)) {
decorations.push(this._createDecoration(lineNumber, l10n.t('Uncommitted change')));
continue;
}
// Recalculate the line number factoring in the diff information
const lineNumberWithDiff = mapLineNumber(lineNumber + 1, diffInformation.diff);
const blameInformation = blameInformationCollection.find(blameInformation => {
return blameInformation.ranges.find(range => {
return lineNumberWithDiff >= range.startLineNumber && lineNumberWithDiff <= range.endLineNumber;
});
});
if (blameInformation) {
const ago = fromNow(blameInformation.date ?? Date.now(), true, true);
decorations.push(this._createDecoration(lineNumber, `${blameInformation.message ?? ''}, ${blameInformation.authorName ?? ''} (${ago})`));
}
}
textEditor.setDecorations(this._decorationType, decorations);
}
private _createDecoration(lineNumber: number, contentText: string): DecorationOptions {
const position = new Position(lineNumber, Number.MAX_SAFE_INTEGER);
return {
range: new Range(position, position),
renderOptions: {
after: {
contentText: `\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${contentText}`
}
},
};
}
private async _getBlameInformation(document: TextDocument): Promise<BlameInformation[] | undefined> {
const repository = this._model.getRepository(document.uri);
if (!repository || !repository.HEAD?.commit) {
return undefined;
}
const repositoryBlameInformation = this._blameInformation.get(repository) ?? {
commit: repository.HEAD.commit,
blameInformation: new Map<Uri, BlameInformation[]>()
} satisfies RepositoryBlameInformation;
let fileBlameInformation = repositoryBlameInformation.blameInformation.get(document.uri);
if (repositoryBlameInformation.commit === repository.HEAD.commit && fileBlameInformation) {
return fileBlameInformation;
}
fileBlameInformation = await repository.blame2(document.uri.fsPath) ?? [];
this._blameInformation.set(repository, {
...repositoryBlameInformation,
blameInformation: repositoryBlameInformation.blameInformation.set(document.uri, fileBlameInformation)
});
return fileBlameInformation;
}
dispose() {
for (const disposables of this._repositoryDisposables.values()) {
dispose(disposables);
}
this._repositoryDisposables.clear();
this._disposables = dispose(this._disposables);
}
}

Просмотреть файл

@ -1054,6 +1054,76 @@ function parseGitChanges(repositoryRoot: string, raw: string): Change[] {
return result;
}
export interface BlameInformation {
readonly id: string;
readonly date?: number;
readonly message?: string;
readonly authorName?: string;
readonly authorEmail?: string;
readonly ranges: {
readonly startLineNumber: number;
readonly endLineNumber: number;
}[];
}
function parseGitBlame(data: string): BlameInformation[] {
const lineSeparator = /\r?\n/;
const commitRegex = /^([0-9a-f]{40})/gm;
const blameInformation = new Map<string, BlameInformation>();
let commitHash: string | undefined = undefined;
let authorName: string | undefined = undefined;
let authorEmail: string | undefined = undefined;
let authorTime: number | undefined = undefined;
let message: string | undefined = undefined;
let startLineNumber: number | undefined = undefined;
let endLineNumber: number | undefined = undefined;
for (const line of data.split(lineSeparator)) {
// Commit
const commitMatch = line.match(commitRegex);
if (!commitHash && commitMatch) {
const segments = line.split(' ');
commitHash = commitMatch[0];
startLineNumber = Number(segments[2]);
endLineNumber = Number(segments[2]) + Number(segments[3]) - 1;
}
// Commit properties
if (commitHash && line.startsWith('author ')) {
authorName = line.substring('author '.length);
}
if (commitHash && line.startsWith('author-mail ')) {
authorEmail = line.substring('author-mail '.length);
}
if (commitHash && line.startsWith('author-time ')) {
authorTime = Number(line.substring('author-time '.length)) * 1000;
}
if (commitHash && line.startsWith('summary ')) {
message = line.substring('summary '.length);
}
// Commit end
if (commitHash && startLineNumber && endLineNumber && line.startsWith('filename ')) {
const existingCommit = blameInformation.get(commitHash);
if (existingCommit) {
existingCommit.ranges.push({ startLineNumber, endLineNumber });
blameInformation.set(commitHash, existingCommit);
} else {
blameInformation.set(commitHash, {
id: commitHash, authorName, authorEmail, date: authorTime, message, ranges: [{ startLineNumber, endLineNumber }]
});
}
commitHash = authorName = authorEmail = authorTime = message = startLineNumber = endLineNumber = undefined;
}
}
return Array.from(blameInformation.values());
}
export interface PullOptions {
unshallow?: boolean;
tags?: boolean;
@ -2137,6 +2207,18 @@ export class Repository {
}
}
async blame2(path: string): Promise<BlameInformation[] | undefined> {
try {
const args = ['blame', '--root', '--incremental', '--', sanitizePath(path)];
const result = await this.exec(args);
return parseGitBlame(result.stdout.trim());
}
catch (err) {
return undefined;
}
}
async createStash(message?: string, includeUntracked?: boolean, staged?: boolean): Promise<void> {
try {
const args = ['stash', 'push'];

Просмотреть файл

@ -26,6 +26,7 @@ import { GitEditor, GitEditorDocumentLinkProvider } from './gitEditor';
import { GitPostCommitCommandsProvider } from './postCommitCommands';
import { GitEditSessionIdentityProvider } from './editSessionIdentityProvider';
import { GitCommitInputBoxCodeActionsProvider, GitCommitInputBoxDiagnosticsManager } from './diagnostics';
import { GitBlameController } from './blame';
const deactivateTasks: { (): Promise<any> }[] = [];
@ -112,6 +113,7 @@ async function createModel(context: ExtensionContext, logger: LogOutputChannel,
cc,
new GitFileSystemProvider(model),
new GitDecorations(model),
new GitBlameController(model),
new GitTimelineProvider(model, cc),
new GitEditSessionIdentityProvider(model),
new TerminalShellExecutionManager(model, logger)

Просмотреть файл

@ -134,7 +134,7 @@ export type TagOperation = BaseOperation & { kind: OperationKind.Tag };
export const Operation = {
Add: (showProgress: boolean): AddOperation => ({ kind: OperationKind.Add, blocking: false, readOnly: false, remote: false, retry: false, showProgress }),
Apply: { kind: OperationKind.Apply, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as ApplyOperation,
Blame: { kind: OperationKind.Blame, blocking: false, readOnly: true, remote: false, retry: false, showProgress: true } as BlameOperation,
Blame: (showProgress: boolean) => ({ kind: OperationKind.Blame, blocking: false, readOnly: true, remote: false, retry: false, showProgress } as BlameOperation),
Branch: { kind: OperationKind.Branch, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as BranchOperation,
CheckIgnore: { kind: OperationKind.CheckIgnore, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as CheckIgnoreOperation,
CherryPick: { kind: OperationKind.CherryPick, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as CherryPickOperation,

Просмотреть файл

@ -14,7 +14,7 @@ import { Branch, BranchQuery, Change, CommitOptions, FetchOptions, ForcePushMode
import { AutoFetcher } from './autofetch';
import { GitBranchProtectionProvider, IBranchProtectionProviderRegistry } from './branchProtection';
import { debounce, memoize, throttle } from './decorators';
import { Repository as BaseRepository, Commit, GitError, LogFileOptions, LsTreeElement, PullOptions, Stash, Submodule } from './git';
import { Repository as BaseRepository, BlameInformation, Commit, GitError, LogFileOptions, LsTreeElement, PullOptions, Stash, Submodule } from './git';
import { GitHistoryProvider } from './historyProvider';
import { Operation, OperationKind, OperationManager, OperationResult } from './operation';
import { CommitCommandsCenter, IPostCommitCommandsProviderRegistry } from './postCommitCommands';
@ -1783,7 +1783,11 @@ export class Repository implements Disposable {
}
async blame(path: string): Promise<string> {
return await this.run(Operation.Blame, () => this.repository.blame(path));
return await this.run(Operation.Blame(true), () => this.repository.blame(path));
}
async blame2(path: string): Promise<BlameInformation[] | undefined> {
return await this.run(Operation.Blame(false), () => this.repository.blame2(path));
}
@throttle

Просмотреть файл

@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef } from 'vscode';
import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n } from 'vscode';
import { dirname, sep, relative } from 'path';
import { Readable } from 'stream';
import { promises as fs, createReadStream } from 'fs';
@ -568,3 +568,197 @@ export function deltaHistoryItemRefs(before: SourceControlHistoryItemRef[], afte
return { added, modified, removed };
}
const minute = 60;
const hour = minute * 60;
const day = hour * 24;
const week = day * 7;
const month = day * 30;
const year = day * 365;
/**
* Create a l10n.td difference of the time between now and the specified date.
* @param date The date to generate the difference from.
* @param appendAgoLabel Whether to append the " ago" to the end.
* @param useFullTimeWords Whether to use full words (eg. seconds) instead of
* shortened (eg. secs).
* @param disallowNow Whether to disallow the string "now" when the difference
* is less than 30 seconds.
*/
export function fromNow(date: number | Date, appendAgoLabel?: boolean, useFullTimeWords?: boolean, disallowNow?: boolean): string {
if (typeof date !== 'number') {
date = date.getTime();
}
const seconds = Math.round((new Date().getTime() - date) / 1000);
if (seconds < -30) {
return l10n.t('in {0}', fromNow(new Date().getTime() + seconds * 1000, false));
}
if (!disallowNow && seconds < 30) {
return l10n.t('now');
}
let value: number;
if (seconds < minute) {
value = seconds;
if (appendAgoLabel) {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} second ago', value)
: l10n.t('{0} sec ago', value);
} else {
return useFullTimeWords
? l10n.t('{0} seconds ago', value)
: l10n.t('{0} secs ago', value);
}
} else {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} second', value)
: l10n.t('{0} sec', value);
} else {
return useFullTimeWords
? l10n.t('{0} seconds', value)
: l10n.t('{0} secs', value);
}
}
}
if (seconds < hour) {
value = Math.floor(seconds / minute);
if (appendAgoLabel) {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} minute ago', value)
: l10n.t('{0} min ago', value);
} else {
return useFullTimeWords
? l10n.t('{0} minutes ago', value)
: l10n.t('{0} mins ago', value);
}
} else {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} minute', value)
: l10n.t('{0} min', value);
} else {
return useFullTimeWords
? l10n.t('{0} minutes', value)
: l10n.t('{0} mins', value);
}
}
}
if (seconds < day) {
value = Math.floor(seconds / hour);
if (appendAgoLabel) {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} hour ago', value)
: l10n.t('{0} hr ago', value);
} else {
return useFullTimeWords
? l10n.t('{0} hours ago', value)
: l10n.t('{0} hrs ago', value);
}
} else {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} hour', value)
: l10n.t('{0} hr', value);
} else {
return useFullTimeWords
? l10n.t('{0} hours', value)
: l10n.t('{0} hrs', value);
}
}
}
if (seconds < week) {
value = Math.floor(seconds / day);
if (appendAgoLabel) {
return value === 1
? l10n.t('{0} day ago', value)
: l10n.t('{0} days ago', value);
} else {
return value === 1
? l10n.t('{0} day', value)
: l10n.t('{0} days', value);
}
}
if (seconds < month) {
value = Math.floor(seconds / week);
if (appendAgoLabel) {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} week ago', value)
: l10n.t('{0} wk ago', value);
} else {
return useFullTimeWords
? l10n.t('{0} weeks ago', value)
: l10n.t('{0} wks ago', value);
}
} else {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} week', value)
: l10n.t('{0} wk', value);
} else {
return useFullTimeWords
? l10n.t('{0} weeks', value)
: l10n.t('{0} wks', value);
}
}
}
if (seconds < year) {
value = Math.floor(seconds / month);
if (appendAgoLabel) {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} month ago', value)
: l10n.t('{0} mo ago', value);
} else {
return useFullTimeWords
? l10n.t('{0} months ago', value)
: l10n.t('{0} mos ago', value);
}
} else {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} month', value)
: l10n.t('{0} mo', value);
} else {
return useFullTimeWords
? l10n.t('{0} months', value)
: l10n.t('{0} mos', value);
}
}
}
value = Math.floor(seconds / year);
if (appendAgoLabel) {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} year ago', value)
: l10n.t('{0} yr ago', value);
} else {
return useFullTimeWords
? l10n.t('{0} years ago', value)
: l10n.t('{0} yrs ago', value);
}
} else {
if (value === 1) {
return useFullTimeWords
? l10n.t('{0} year', value)
: l10n.t('{0} yr', value);
} else {
return useFullTimeWords
? l10n.t('{0} years', value)
: l10n.t('{0} yrs', value);
}
}
}