Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 14 additions & 69 deletions extensions/git/src/blame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation }
import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
import { AvatarQuery, AvatarQueryCommit } from './api/git';
import { LRUCache } from './cache';

const AVATAR_SIZE = 20;
import { AVATAR_SIZE, getHistoryItemHover, getHistoryItemHoverCommitHashCommands, processHistoryItemRemoteHoverCommands } from './historyProvider';

function lineRangesContainLine(changes: readonly TextEditorChange[], lineNumber: number): boolean {
return changes.some(c => c.modified.startLineNumber <= lineNumber && lineNumber < c.modified.endLineNumberExclusive);
Expand Down Expand Up @@ -242,80 +241,26 @@ export class GitBlameController {
this._model, repository, commitInformation?.message ?? blameInformation.subject ?? '');
}

const markdownString = new MarkdownString();
markdownString.isTrusted = true;
markdownString.supportThemeIcons = true;

// Author, date
const hash = commitInformation?.hash ?? blameInformation.hash;
const authorName = commitInformation?.authorName ?? blameInformation.authorName;
const authorEmail = commitInformation?.authorEmail ?? blameInformation.authorEmail;
const authorDate = commitInformation?.authorDate ?? blameInformation.authorDate;
const avatar = commitAvatar ? `![${authorName}](${commitAvatar}|width=${AVATAR_SIZE},height=${AVATAR_SIZE})` : '$(account)';


if (authorName) {
if (authorEmail) {
const emailTitle = l10n.t('Email');
markdownString.appendMarkdown(`${avatar} [**${authorName}**](mailto:${authorEmail} "${emailTitle} ${authorName}")`);
} else {
markdownString.appendMarkdown(`${avatar} **${authorName}**`);
}

if (authorDate) {
const dateString = new Date(authorDate).toLocaleString(undefined, {
year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric'
});
markdownString.appendMarkdown(`, $(history) ${fromNow(authorDate, true, true)} (${dateString})`);
}

markdownString.appendMarkdown('\n\n');
}

// Subject | Message
const message = commitMessageWithLinks ?? commitInformation?.message ?? blameInformation.subject ?? '';
markdownString.appendMarkdown(`${emojify(message.replace(/\r\n|\r|\n/g, '\n\n'))}\n\n`);
markdownString.appendMarkdown(`---\n\n`);

// Short stats
if (commitInformation?.shortStat) {
markdownString.appendMarkdown(`<span>${commitInformation.shortStat.files === 1 ?
l10n.t('{0} file changed', commitInformation.shortStat.files) :
l10n.t('{0} files changed', commitInformation.shortStat.files)}</span>`);

if (commitInformation.shortStat.insertions) {
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${commitInformation.shortStat.insertions === 1 ?
l10n.t('{0} insertion{1}', commitInformation.shortStat.insertions, '(+)') :
l10n.t('{0} insertions{1}', commitInformation.shortStat.insertions, '(+)')}</span>`);
}

if (commitInformation.shortStat.deletions) {
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${commitInformation.shortStat.deletions === 1 ?
l10n.t('{0} deletion{1}', commitInformation.shortStat.deletions, '(-)') :
l10n.t('{0} deletions{1}', commitInformation.shortStat.deletions, '(-)')}</span>`);
}

markdownString.appendMarkdown(`\n\n---\n\n`);
}

// Commands
markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, hash, documentUri]))} "${l10n.t('Open Commit')}")`);
markdownString.appendMarkdown('&nbsp;');
markdownString.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`);

// Remote hover commands
if (remoteHoverCommands.length > 0) {
markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');

const remoteCommandsMarkdown = remoteHoverCommands
.map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`);
markdownString.appendMarkdown(remoteCommandsMarkdown.join('&nbsp;'));
}

markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');
markdownString.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%5B%22git.blame%22%5D "${l10n.t('Open Settings')}")`);

return markdownString;
const commands: Command[][] = [
getHistoryItemHoverCommitHashCommands(documentUri, hash),
processHistoryItemRemoteHoverCommands(remoteHoverCommands, hash)
];

commands.push([{
title: `$(gear)`,
tooltip: l10n.t('Open Settings'),
command: 'workbench.action.openSettings',
arguments: ['git.blame']
}] satisfies Command[]);

return getHistoryItemHover(commitAvatar, authorName, authorEmail, authorDate, message, commitInformation?.shortStat, commands);
}

private _onDidChangeConfiguration(e?: ConfigurationChangeEvent): void {
Expand Down
110 changes: 107 additions & 3 deletions extensions/git/src/historyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
*--------------------------------------------------------------------------------------------*/


import { CancellationToken, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent, workspace, ConfigurationChangeEvent } from 'vscode';
import { CancellationToken, Disposable, Event, EventEmitter, FileDecoration, FileDecorationProvider, SourceControlHistoryItem, SourceControlHistoryItemChange, SourceControlHistoryOptions, SourceControlHistoryProvider, ThemeIcon, Uri, window, LogOutputChannel, SourceControlHistoryItemRef, l10n, SourceControlHistoryItemRefsChangeEvent, workspace, ConfigurationChangeEvent, MarkdownString, Command } from 'vscode';
import { Repository, Resource } from './repository';
import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, subject, truncate } from './util';
import { IDisposable, deltaHistoryItemRefs, dispose, filterEvent, fromNow, getCommitShortHash, subject, truncate } from './util';
import { toMultiFileDiffEditorUris } from './uri';
import { AvatarQuery, AvatarQueryCommit, Branch, LogOptions, Ref, RefType } from './api/git';
import { emojify, ensureEmojis } from './emoji';
import { Commit } from './git';
import { Commit, CommitShortStat } from './git';
import { OperationKind, OperationResult } from './operation';
import { ISourceControlHistoryItemDetailsProviderRegistry, provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
import { throttle } from './decorators';
Expand Down Expand Up @@ -590,3 +590,107 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec
dispose(this.disposables);
}
}

export const AVATAR_SIZE = 20;

export function getHistoryItemHoverCommitHashCommands(documentUri: Uri, hash: string): Command[] {
return [{
title: `$(git-commit) ${getCommitShortHash(documentUri, hash)}`,
tooltip: l10n.t('Open Commit'),
command: 'git.viewCommit',
arguments: [documentUri, hash, documentUri]
}, {
title: `$(copy)`,
tooltip: l10n.t('Copy Commit Hash'),
command: 'git.copyContentToClipboard',
arguments: [hash]
}] satisfies Command[];
}

export function processHistoryItemRemoteHoverCommands(commands: Command[], hash: string): Command[] {
return commands.map(command => ({
...command,
arguments: [...command.arguments ?? [], hash]
} satisfies Command));
}

export function getHistoryItemHover(authorAvatar: string | undefined, authorName: string | undefined, authorEmail: string | undefined, authorDate: Date | number | undefined, message: string, shortStats: CommitShortStat | undefined, commands: Command[][] | undefined): MarkdownString {
const markdownString = new MarkdownString('', true);
markdownString.isTrusted = true;

if (authorName) {
const avatar = authorAvatar ? `![${authorName}](${authorAvatar}|width=${AVATAR_SIZE},height=${AVATAR_SIZE})` : '$(account)';

if (authorEmail) {
const emailTitle = l10n.t('Email');
markdownString.appendMarkdown(`${avatar} [**${authorName}**](mailto:${authorEmail} "${emailTitle} ${authorName}")`);
} else {
markdownString.appendMarkdown(`${avatar} **${authorName}**`);
}

if (authorDate) {
const dateString = new Date(authorDate).toLocaleString(undefined, {
year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric'
});
markdownString.appendMarkdown(`, $(history) ${fromNow(authorDate, true, true)} (${dateString})`);
}

markdownString.appendMarkdown('\n\n');
}

// Subject | Message
markdownString.appendMarkdown(`${emojify(message.replace(/\r\n|\r|\n/g, '\n\n'))}\n\n`);
markdownString.appendMarkdown(`---\n\n`);

// Short stats
if (shortStats) {
markdownString.appendMarkdown(`<span>${shortStats.files === 1 ?
l10n.t('{0} file changed', shortStats.files) :
l10n.t('{0} files changed', shortStats.files)}</span>`);

if (shortStats.insertions) {
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${shortStats.insertions === 1 ?
l10n.t('{0} insertion{1}', shortStats.insertions, '(+)') :
l10n.t('{0} insertions{1}', shortStats.insertions, '(+)')}</span>`);
}

if (shortStats.deletions) {
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${shortStats.deletions === 1 ?
l10n.t('{0} deletion{1}', shortStats.deletions, '(-)') :
l10n.t('{0} deletions{1}', shortStats.deletions, '(-)')}</span>`);
}

markdownString.appendMarkdown(`\n\n---\n\n`);
}

// Commands
if (commands && commands.length > 0) {
for (let index = 0; index < commands.length; index++) {
if (index !== 0) {
markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');
}

const commandsMarkdown = commands[index]
.map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify(command.arguments))} "${command.tooltip}")`);
markdownString.appendMarkdown(commandsMarkdown.join('&nbsp;'));
}
}

// markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, hash, documentUri]))} "${l10n.t('Open Commit')}")`);
// markdownString.appendMarkdown('&nbsp;');
// markdownString.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`);

// // Remote hover commands
// if (commands && commands.length > 0) {
// markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');

// const remoteCommandsMarkdown = commands
// .map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`);
// markdownString.appendMarkdown(remoteCommandsMarkdown.join('&nbsp;'));
// }

// markdownString.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');
// markdownString.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%5B%22git.blame%22%5D "${l10n.t('Open Settings')}")`);

return markdownString;
}
77 changes: 11 additions & 66 deletions extensions/git/src/timelineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,17 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CancellationToken, ConfigurationChangeEvent, Disposable, env, Event, EventEmitter, MarkdownString, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace, l10n, Command } from 'vscode';
import { CancellationToken, ConfigurationChangeEvent, Disposable, Event, EventEmitter, ThemeIcon, Timeline, TimelineChangeEvent, TimelineItem, TimelineOptions, TimelineProvider, Uri, workspace, l10n, Command } from 'vscode';
import { Model } from './model';
import { Repository, Resource } from './repository';
import { debounce } from './decorators';
import { emojify, ensureEmojis } from './emoji';
import { CommandCenter } from './commands';
import { OperationKind, OperationResult } from './operation';
import { truncate } from './util';
import { CommitShortStat } from './git';
import { provideSourceControlHistoryItemAvatar, provideSourceControlHistoryItemHoverCommands, provideSourceControlHistoryItemMessageLinks } from './historyItemDetailsProvider';
import { AvatarQuery, AvatarQueryCommit } from './api/git';

const AVATAR_SIZE = 20;
import { getHistoryItemHover, getHistoryItemHoverCommitHashCommands, processHistoryItemRemoteHoverCommands } from './historyProvider';

export class GitTimelineItem extends TimelineItem {
static is(item: TimelineItem): item is GitTimelineItem {
Expand Down Expand Up @@ -54,61 +52,6 @@ export class GitTimelineItem extends TimelineItem {
return this.shortenRef(this.previousRef);
}

setItemDetails(uri: Uri, hash: string | undefined, shortHash: string | undefined, avatar: string | undefined, author: string, email: string | undefined, date: string, message: string, shortStat?: CommitShortStat, remoteSourceCommands: Command[] = []): void {
this.tooltip = new MarkdownString('', true);
this.tooltip.isTrusted = true;

const avatarMarkdown = avatar
? `![${author}](${avatar}|width=${AVATAR_SIZE},height=${AVATAR_SIZE})`
: '$(account)';

if (email) {
const emailTitle = l10n.t('Email');
this.tooltip.appendMarkdown(`${avatarMarkdown} [**${author}**](mailto:${email} "${emailTitle} ${author}")`);
} else {
this.tooltip.appendMarkdown(`${avatarMarkdown} **${author}**`);
}

this.tooltip.appendMarkdown(`, $(history) ${date}\n\n`);
this.tooltip.appendMarkdown(`${message}\n\n`);

if (shortStat) {
this.tooltip.appendMarkdown(`---\n\n`);

const labels: string[] = [];
if (shortStat.insertions) {
labels.push(`<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${shortStat.insertions === 1 ?
l10n.t('{0} insertion{1}', shortStat.insertions, '(+)') :
l10n.t('{0} insertions{1}', shortStat.insertions, '(+)')}</span>`);
}

if (shortStat.deletions) {
labels.push(`<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${shortStat.deletions === 1 ?
l10n.t('{0} deletion{1}', shortStat.deletions, '(-)') :
l10n.t('{0} deletions{1}', shortStat.deletions, '(-)')}</span>`);
}

this.tooltip.appendMarkdown(`${labels.join(', ')}\n\n`);
}

if (hash && shortHash) {
this.tooltip.appendMarkdown(`---\n\n`);

this.tooltip.appendMarkdown(`[\`$(git-commit) ${shortHash} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([uri, hash, uri]))} "${l10n.t('Open Commit')}")`);
this.tooltip.appendMarkdown('&nbsp;');
this.tooltip.appendMarkdown(`[$(copy)](command:git.copyContentToClipboard?${encodeURIComponent(JSON.stringify(hash))} "${l10n.t('Copy Commit Hash')}")`);

// Remote commands
if (remoteSourceCommands.length > 0) {
this.tooltip.appendMarkdown('&nbsp;&nbsp;|&nbsp;&nbsp;');

const remoteCommandsMarkdown = remoteSourceCommands
.map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`);
this.tooltip.appendMarkdown(remoteCommandsMarkdown.join('&nbsp;'));
}
}
}

private shortenRef(ref: string): string {
if (ref === '' || ref === '~' || ref === 'HEAD') {
return ref;
Expand Down Expand Up @@ -215,13 +158,10 @@ export class GitTimelineProvider implements TimelineProvider {
commits.splice(commits.length - 1, 1);
}

const dateFormatter = new Intl.DateTimeFormat(env.language, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });

const config = workspace.getConfiguration('git', Uri.file(repo.root));
const dateType = config.get<'committed' | 'authored'>('timeline.date');
const showAuthor = config.get<boolean>('timeline.showAuthor');
const showUncommitted = config.get<boolean>('timeline.showUncommitted');
const commitShortHashLength = config.get<number>('commitShortHashLength') ?? 7;

const openComparison = l10n.t('Open Comparison');

Expand Down Expand Up @@ -254,10 +194,15 @@ export class GitTimelineProvider implements TimelineProvider {
item.description = c.authorName;
}

const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteHoverCommands : [];
const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteHoverCommands ?? [] : [];
const messageWithLinks = await provideSourceControlHistoryItemMessageLinks(this.model, repo, message) ?? message;

item.setItemDetails(uri, c.hash, truncate(c.hash, commitShortHashLength, false), avatars?.get(c.hash), c.authorName!, c.authorEmail, dateFormatter.format(date), messageWithLinks, c.shortStat, commitRemoteSourceCommands);
const commands: Command[][] = [
getHistoryItemHoverCommitHashCommands(uri, c.hash),
processHistoryItemRemoteHoverCommands(commitRemoteSourceCommands, c.hash)
];

item.tooltip = getHistoryItemHover(avatars?.get(c.hash), c.authorName, c.authorEmail, date, messageWithLinks, c.shortStat, commands);

const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);
if (cmd) {
Expand All @@ -282,7 +227,7 @@ export class GitTimelineProvider implements TimelineProvider {
// TODO@eamodio: Replace with a better icon -- reflecting its status maybe?
item.iconPath = new ThemeIcon('git-commit');
item.description = '';
item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(index.type));
item.tooltip = getHistoryItemHover(undefined, you, undefined, date, Resource.getStatusText(index.type), undefined, undefined);

const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);
if (cmd) {
Expand All @@ -304,7 +249,7 @@ export class GitTimelineProvider implements TimelineProvider {
const item = new GitTimelineItem('', index ? '~' : 'HEAD', l10n.t('Uncommitted Changes'), date.getTime(), 'working', 'git:file:working');
item.iconPath = new ThemeIcon('circle-outline');
item.description = '';
item.setItemDetails(uri, undefined, undefined, undefined, you, undefined, dateFormatter.format(date), Resource.getStatusText(working.type));
item.tooltip = getHistoryItemHover(undefined, you, undefined, date, Resource.getStatusText(working.type), undefined, undefined);

const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri);
if (cmd) {
Expand Down
Loading