Skip to content

Commit

Permalink
GitHub - add "Open on GitHub" to blame hover (#237514)
Browse files Browse the repository at this point in the history
* WIP - saving my work

* Refactor hover rendering code
  • Loading branch information
lszomoru authored Jan 8, 2025
1 parent 1329d03 commit dca80ea
Show file tree
Hide file tree
Showing 11 changed files with 146 additions and 56 deletions.
8 changes: 6 additions & 2 deletions extensions/git-base/src/api/api1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, commands } from 'vscode';
import { Command, Disposable, commands } from 'vscode';
import { Model } from '../model';
import { getRemoteSourceActions, pickRemoteSource } from '../remoteSource';
import { getRemoteSourceActions, getRemoteSourceControlHistoryItemCommands, pickRemoteSource } from '../remoteSource';
import { GitBaseExtensionImpl } from './extension';
import { API, PickRemoteSourceOptions, PickRemoteSourceResult, RemoteSourceAction, RemoteSourceProvider } from './git-base';

Expand All @@ -21,6 +21,10 @@ export class ApiImpl implements API {
return getRemoteSourceActions(this._model, url);
}

getRemoteSourceControlHistoryItemCommands(url: string): Promise<Command[]> {
return getRemoteSourceControlHistoryItemCommands(this._model, url);
}

registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable {
return this._model.registerRemoteSourceProvider(provider);
}
Expand Down
5 changes: 4 additions & 1 deletion extensions/git-base/src/api/git-base.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, Event, ProviderResult, Uri } from 'vscode';
import { Command, Disposable, Event, ProviderResult } from 'vscode';
export { ProviderResult } from 'vscode';

export interface API {
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
getRemoteSourceActions(url: string): Promise<RemoteSourceAction[]>;
getRemoteSourceControlHistoryItemCommands(url: string): Promise<Command[]>;
pickRemoteSource(options: PickRemoteSourceOptions): Promise<string | PickRemoteSourceResult | undefined>;
}

Expand Down Expand Up @@ -80,6 +82,7 @@ export interface RemoteSourceProvider {

getBranches?(url: string): ProviderResult<string[]>;
getRemoteSourceActions?(url: string): ProviderResult<RemoteSourceAction[]>;
getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult<Command[]>;
getRecentRemoteSources?(query?: string): ProviderResult<RecentRemoteSource[]>;
getRemoteSources(query?: string): ProviderResult<RemoteSource[]>;
}
16 changes: 15 additions & 1 deletion extensions/git-base/src/remoteSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { QuickPickItem, window, QuickPick, QuickPickItemKind, l10n, Disposable } from 'vscode';
import { QuickPickItem, window, QuickPick, QuickPickItemKind, l10n, Disposable, Command } from 'vscode';
import { RemoteSourceProvider, RemoteSource, PickRemoteSourceOptions, PickRemoteSourceResult, RemoteSourceAction } from './api/git-base';
import { Model } from './model';
import { throttle, debounce } from './decorators';
Expand Down Expand Up @@ -123,6 +123,20 @@ export async function getRemoteSourceActions(model: Model, url: string): Promise
return remoteSourceActions;
}

export async function getRemoteSourceControlHistoryItemCommands(model: Model, url: string): Promise<Command[]> {
const providers = model.getRemoteProviders();

const remoteSourceCommands = [];
for (const provider of providers) {
const providerCommands = await provider.getRemoteSourceControlHistoryItemCommands?.(url);
if (providerCommands?.length) {
remoteSourceCommands.push(...providerCommands);
}
}

return remoteSourceCommands;
}

export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise<string | undefined>;
export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch: true }): Promise<PickRemoteSourceResult | undefined>;
export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise<string | PickRemoteSourceResult | undefined> {
Expand Down
104 changes: 64 additions & 40 deletions extensions/git/src/blame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { BlameInformation, Commit } from './git';
import { fromGitUri, isGitUri } from './uri';
import { emojify, ensureEmojis } from './emoji';
import { getWorkingTreeAndIndexDiffInformation, getWorkingTreeDiffInformation } from './staging';
import { getRemoteSourceControlHistoryItemCommands } from './remoteSource';

function lineRangesContainLine(changes: readonly TextEditorChange[], lineNumber: number): boolean {
return changes.some(c => c.modified.startLineNumber <= lineNumber && lineNumber < c.modified.endLineNumberExclusive);
Expand Down Expand Up @@ -60,10 +61,6 @@ function getEditorDecorationRange(lineNumber: number): Range {
return new Range(position, position);
}

function isBlameInformation(object: any): object is BlameInformation {
return Array.isArray((object as BlameInformation).ranges);
}

function isResourceSchemeSupported(uri: Uri): boolean {
return uri.scheme === 'file' || isGitUri(uri);
}
Expand Down Expand Up @@ -206,68 +203,95 @@ export class GitBlameController {
});
}

async getBlameInformationDetailedHover(documentUri: Uri, blameInformation: BlameInformation): Promise<MarkdownString | undefined> {
async getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation, includeCommitDetails = false): Promise<MarkdownString> {
let commitInformation: Commit | undefined;
const remoteSourceCommands: Command[] = [];

const repository = this._model.getRepository(documentUri);
if (!repository) {
return this.getBlameInformationHover(documentUri, blameInformation);
}
if (repository) {
// Commit details
if (includeCommitDetails) {
try {
commitInformation = await repository.getCommit(blameInformation.hash);
} catch { }
}

try {
const commit = await repository.getCommit(blameInformation.hash);
return this.getBlameInformationHover(documentUri, commit);
} catch {
return this.getBlameInformationHover(documentUri, blameInformation);
// Remote commands
const defaultRemote = repository.getDefaultRemote();
if (defaultRemote?.fetchUrl) {
remoteSourceCommands.push(...await getRemoteSourceControlHistoryItemCommands(defaultRemote.fetchUrl));
}
}
}

getBlameInformationHover(documentUri: Uri, blameInformationOrCommit: BlameInformation | Commit): MarkdownString {
const markdownString = new MarkdownString();
markdownString.isTrusted = true;
markdownString.supportHtml = true;
markdownString.supportThemeIcons = true;

if (blameInformationOrCommit.authorName) {
if (blameInformationOrCommit.authorEmail) {
// Author, date
const authorName = commitInformation?.authorName ?? blameInformation.authorName;
const authorEmail = commitInformation?.authorEmail ?? blameInformation.authorEmail;
const authorDate = commitInformation?.authorDate ?? blameInformation.authorDate;

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

if (blameInformationOrCommit.authorDate) {
const dateString = new Date(blameInformationOrCommit.authorDate).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' });
markdownString.appendMarkdown(`, $(history) ${fromNow(blameInformationOrCommit.authorDate, true, true)} (${dateString})`);
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');
}

markdownString.appendMarkdown(`${emojify(isBlameInformation(blameInformationOrCommit) ? blameInformationOrCommit.subject ?? '' : blameInformationOrCommit.message)}\n\n`);
// Subject | Message
markdownString.appendMarkdown(`${emojify(commitInformation?.message ?? blameInformation.subject ?? '')}\n\n`);
markdownString.appendMarkdown(`---\n\n`);

if (!isBlameInformation(blameInformationOrCommit) && blameInformationOrCommit.shortStat) {
markdownString.appendMarkdown(`<span>${blameInformationOrCommit.shortStat.files === 1 ?
l10n.t('{0} file changed', blameInformationOrCommit.shortStat.files) :
l10n.t('{0} files changed', blameInformationOrCommit.shortStat.files)}</span>`);
// 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 (blameInformationOrCommit.shortStat.insertions) {
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverAdditionsForeground);">${blameInformationOrCommit.shortStat.insertions === 1 ?
l10n.t('{0} insertion{1}', blameInformationOrCommit.shortStat.insertions, '(+)') :
l10n.t('{0} insertions{1}', blameInformationOrCommit.shortStat.insertions, '(+)')}</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 (blameInformationOrCommit.shortStat.deletions) {
markdownString.appendMarkdown(`,&nbsp;<span style="color:var(--vscode-scmGraph-historyItemHoverDeletionsForeground);">${blameInformationOrCommit.shortStat.deletions === 1 ?
l10n.t('{0} deletion{1}', blameInformationOrCommit.shortStat.deletions, '(-)') :
l10n.t('{0} deletions{1}', blameInformationOrCommit.shortStat.deletions, '(-)')}</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`);
}

markdownString.appendMarkdown(`[\`$(git-commit) ${getCommitShortHash(documentUri, blameInformationOrCommit.hash)} \`](command:git.viewCommit?${encodeURIComponent(JSON.stringify([documentUri, blameInformationOrCommit.hash]))} "${l10n.t('View Commit')}")`);
// Commands
const hash = commitInformation?.hash ?? blameInformation.hash;

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

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

const remoteCommandsMarkdown = remoteSourceCommands
.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')}")`);

Expand Down Expand Up @@ -566,7 +590,7 @@ class GitBlameEditorDecoration implements HoverProvider {
return undefined;
}

const contents = await this._controller.getBlameInformationDetailedHover(textEditor.document.uri, lineBlameInformation.blameInformation);
const contents = await this._controller.getBlameInformationHover(textEditor.document.uri, lineBlameInformation.blameInformation, true);

if (!contents || token.isCancellationRequested) {
return undefined;
Expand Down Expand Up @@ -678,7 +702,7 @@ class GitBlameStatusBarItem {
this._onDidChangeBlameInformation();
}

private _onDidChangeBlameInformation(): void {
private async _onDidChangeBlameInformation(): Promise<void> {
if (!window.activeTextEditor) {
this._statusBarItem.hide();
return;
Expand All @@ -699,7 +723,7 @@ class GitBlameStatusBarItem {
const template = config.get<string>('blame.statusBarItem.template', '${authorName} (${authorDateAgo})');

this._statusBarItem.text = `$(git-commit) ${this._controller.formatBlameInformationMessage(window.activeTextEditor.document.uri, template, blameInformation[0].blameInformation)}`;
this._statusBarItem.tooltip = this._controller.getBlameInformationHover(window.activeTextEditor.document.uri, blameInformation[0].blameInformation);
this._statusBarItem.tooltip = await this._controller.getBlameInformationHover(window.activeTextEditor.document.uri, blameInformation[0].blameInformation);
this._statusBarItem.command = {
title: l10n.t('View Commit'),
command: 'git.viewCommit',
Expand Down
4 changes: 4 additions & 0 deletions extensions/git/src/remoteSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ export async function pickRemoteSource(options: PickRemoteSourceOptions = {}): P
export async function getRemoteSourceActions(url: string) {
return GitBaseApi.getAPI().getRemoteSourceActions(url);
}

export async function getRemoteSourceControlHistoryItemCommands(url: string) {
return GitBaseApi.getAPI().getRemoteSourceControlHistoryItemCommands(url);
}
20 changes: 14 additions & 6 deletions extensions/git/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1592,13 +1592,13 @@ export class Repository implements Disposable {
}

private async getDefaultBranch(): Promise<Branch | undefined> {
try {
if (this.remotes.length === 0) {
return undefined;
}
const defaultRemote = this.getDefaultRemote();
if (!defaultRemote) {
return undefined;
}

const remote = this.remotes.find(r => r.name === 'origin') ?? this.remotes[0];
const defaultBranch = await this.repository.getDefaultBranch(remote.name);
try {
const defaultBranch = await this.repository.getDefaultBranch(defaultRemote.name);
return defaultBranch;
}
catch (err) {
Expand Down Expand Up @@ -1713,6 +1713,14 @@ export class Repository implements Disposable {
await this.run(Operation.DeleteRef, () => this.repository.deleteRef(ref));
}

getDefaultRemote(): Remote | undefined {
if (this.remotes.length === 0) {
return undefined;
}

return this.remotes.find(r => r.name === 'origin') ?? this.remotes[0];
}

async addRemote(name: string, url: string): Promise<void> {
await this.run(Operation.Remote, () => this.repository.addRemote(name, url));
}
Expand Down
8 changes: 5 additions & 3 deletions extensions/git/src/typings/git-base.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, Event, ProviderResult, Uri } from 'vscode';
import { Command, Disposable, Event, ProviderResult } from 'vscode';
export { ProviderResult } from 'vscode';

export interface API {
pickRemoteSource(options: PickRemoteSourceOptions): Promise<string | PickRemoteSourceResult | undefined>;
getRemoteSourceActions(url: string): Promise<RemoteSourceAction[]>;
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
getRemoteSourceActions(url: string): Promise<RemoteSourceAction[]>;
getRemoteSourceControlHistoryItemCommands(url: string): Promise<Command[]>;
pickRemoteSource(options: PickRemoteSourceOptions): Promise<string | PickRemoteSourceResult | undefined>;
}

export interface GitBaseExtension {
Expand Down Expand Up @@ -81,6 +82,7 @@ export interface RemoteSourceProvider {

getBranches?(url: string): ProviderResult<string[]>;
getRemoteSourceActions?(url: string): ProviderResult<RemoteSourceAction[]>;
getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult<Command[]>;
getRecentRemoteSources?(query?: string): ProviderResult<RecentRemoteSource[]>;
getRemoteSources(query?: string): ProviderResult<RemoteSource[]>;
}
7 changes: 6 additions & 1 deletion extensions/github/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as vscode from 'vscode';
import { API as GitAPI } from './typings/git';
import { publishRepository } from './publish';
import { DisposableStore } from './util';
import { LinkContext, getLink, getVscodeDevHost } from './links';
import { LinkContext, getCommitLink, getLink, getVscodeDevHost } from './links';

async function copyVscodeDevLink(gitAPI: GitAPI, useSelection: boolean, context: LinkContext, includeRange = true) {
try {
Expand Down Expand Up @@ -57,6 +57,11 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable {
return copyVscodeDevLink(gitAPI, true, context, false);
}));

disposables.add(vscode.commands.registerCommand('github.openOnGitHub', async (url: string, historyItemId: string) => {
const link = getCommitLink(url, historyItemId);
vscode.env.openExternal(vscode.Uri.parse(link));
}));

disposables.add(vscode.commands.registerCommand('github.openOnVscodeDev', async () => {
return openVscodeDevLink(gitAPI);
}));
Expand Down
9 changes: 9 additions & 0 deletions extensions/github/src/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ export function getBranchLink(url: string, branch: string, hostPrefix: string =
return `${hostPrefix}/${repo.owner}/${repo.repo}/tree/${branch}`;
}

export function getCommitLink(url: string, hash: string, hostPrefix: string = 'https://github.com') {
const repo = getRepositoryFromUrl(url);
if (!repo) {
throw new Error('Invalid repository URL provided');
}

return `${hostPrefix}/${repo.owner}/${repo.repo}/commit/${hash}`;
}

export function getVscodeDevHost(): string {
return `https://${vscode.env.appName.toLowerCase().includes('insiders') ? 'insiders.' : ''}vscode.dev/github`;
}
Expand Down
16 changes: 15 additions & 1 deletion extensions/github/src/remoteSourceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Uri, env, l10n, workspace } from 'vscode';
import { Command, Uri, env, l10n, workspace } from 'vscode';
import { RemoteSourceProvider, RemoteSource, RemoteSourceAction } from './typings/git-base';
import { getOctokit } from './auth';
import { Octokit } from '@octokit/rest';
Expand Down Expand Up @@ -136,4 +136,18 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider {
}
}];
}

async getRemoteSourceControlHistoryItemCommands(url: string): Promise<Command[]> {
const repository = getRepositoryFromUrl(url);
if (!repository) {
return [];
}

return [{
title: l10n.t('{0} Open on GitHub', '$(github)'),
tooltip: l10n.t('Open on GitHub'),
command: 'github.openOnGitHub',
arguments: [url]
}];
}
}
Loading

0 comments on commit dca80ea

Please sign in to comment.