diff --git a/extensions/git-base/src/api/api1.ts b/extensions/git-base/src/api/api1.ts index 005a7930356..74edc7f4452 100644 --- a/extensions/git-base/src/api/api1.ts +++ b/extensions/git-base/src/api/api1.ts @@ -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'; @@ -21,6 +21,10 @@ export class ApiImpl implements API { return getRemoteSourceActions(this._model, url); } + getRemoteSourceControlHistoryItemCommands(url: string): Promise { + return getRemoteSourceControlHistoryItemCommands(this._model, url); + } + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable { return this._model.registerRemoteSourceProvider(provider); } diff --git a/extensions/git-base/src/api/git-base.d.ts b/extensions/git-base/src/api/git-base.d.ts index 53cac4d5c70..37dd2c4229c 100644 --- a/extensions/git-base/src/api/git-base.d.ts +++ b/extensions/git-base/src/api/git-base.d.ts @@ -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; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; } @@ -80,6 +82,7 @@ export interface RemoteSourceProvider { getBranches?(url: string): ProviderResult; getRemoteSourceActions?(url: string): ProviderResult; + getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; } diff --git a/extensions/git-base/src/remoteSource.ts b/extensions/git-base/src/remoteSource.ts index eb86b27367a..8d8d4ab102f 100644 --- a/extensions/git-base/src/remoteSource.ts +++ b/extensions/git-base/src/remoteSource.ts @@ -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'; @@ -123,6 +123,20 @@ export async function getRemoteSourceActions(model: Model, url: string): Promise return remoteSourceActions; } +export async function getRemoteSourceControlHistoryItemCommands(model: Model, url: string): Promise { + 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; export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch: true }): Promise; export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions = {}): Promise { diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index c8f15ffdf94..110b4601b15 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -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); @@ -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); } @@ -206,68 +203,95 @@ export class GitBlameController { }); } - async getBlameInformationDetailedHover(documentUri: Uri, blameInformation: BlameInformation): Promise { + async getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation, includeCommitDetails = false): Promise { + 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 { } + } + + // Remote commands + const defaultRemote = repository.getDefaultRemote(); + if (defaultRemote?.fetchUrl) { + remoteSourceCommands.push(...await getRemoteSourceControlHistoryItemCommands(defaultRemote.fetchUrl)); + } } - try { - const commit = await repository.getCommit(blameInformation.hash); - return this.getBlameInformationHover(documentUri, commit); - } catch { - return this.getBlameInformationHover(documentUri, blameInformation); - } - } - - 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(`${blameInformationOrCommit.shortStat.files === 1 ? - l10n.t('{0} file changed', blameInformationOrCommit.shortStat.files) : - l10n.t('{0} files changed', blameInformationOrCommit.shortStat.files)}`); + // Short stats + if (commitInformation?.shortStat) { + markdownString.appendMarkdown(`${commitInformation.shortStat.files === 1 ? + l10n.t('{0} file changed', commitInformation.shortStat.files) : + l10n.t('{0} files changed', commitInformation.shortStat.files)}`); - if (blameInformationOrCommit.shortStat.insertions) { - markdownString.appendMarkdown(`, ${blameInformationOrCommit.shortStat.insertions === 1 ? - l10n.t('{0} insertion{1}', blameInformationOrCommit.shortStat.insertions, '(+)') : - l10n.t('{0} insertions{1}', blameInformationOrCommit.shortStat.insertions, '(+)')}`); + if (commitInformation.shortStat.insertions) { + markdownString.appendMarkdown(`, ${commitInformation.shortStat.insertions === 1 ? + l10n.t('{0} insertion{1}', commitInformation.shortStat.insertions, '(+)') : + l10n.t('{0} insertions{1}', commitInformation.shortStat.insertions, '(+)')}`); } - if (blameInformationOrCommit.shortStat.deletions) { - markdownString.appendMarkdown(`, ${blameInformationOrCommit.shortStat.deletions === 1 ? - l10n.t('{0} deletion{1}', blameInformationOrCommit.shortStat.deletions, '(-)') : - l10n.t('{0} deletions{1}', blameInformationOrCommit.shortStat.deletions, '(-)')}`); + if (commitInformation.shortStat.deletions) { + markdownString.appendMarkdown(`, ${commitInformation.shortStat.deletions === 1 ? + l10n.t('{0} deletion{1}', commitInformation.shortStat.deletions, '(-)') : + l10n.t('{0} deletions{1}', commitInformation.shortStat.deletions, '(-)')}`); } 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(' '); - 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('  |  '); + + const remoteCommandsMarkdown = remoteSourceCommands + .map(command => `[${command.title}](command:${command.command}?${encodeURIComponent(JSON.stringify([...command.arguments ?? [], hash]))} "${command.tooltip}")`); + markdownString.appendMarkdown(remoteCommandsMarkdown.join(' ')); + } + markdownString.appendMarkdown('  |  '); markdownString.appendMarkdown(`[$(gear)](command:workbench.action.openSettings?%5B%22git.blame%22%5D "${l10n.t('Open Settings')}")`); @@ -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; @@ -678,7 +702,7 @@ class GitBlameStatusBarItem { this._onDidChangeBlameInformation(); } - private _onDidChangeBlameInformation(): void { + private async _onDidChangeBlameInformation(): Promise { if (!window.activeTextEditor) { this._statusBarItem.hide(); return; @@ -699,7 +723,7 @@ class GitBlameStatusBarItem { const template = config.get('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', diff --git a/extensions/git/src/remoteSource.ts b/extensions/git/src/remoteSource.ts index eb63e5db81f..dfdb36fc11f 100644 --- a/extensions/git/src/remoteSource.ts +++ b/extensions/git/src/remoteSource.ts @@ -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); +} diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 00419ef85fc..c576bc790b0 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -1592,13 +1592,13 @@ export class Repository implements Disposable { } private async getDefaultBranch(): Promise { - 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) { @@ -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 { await this.run(Operation.Remote, () => this.repository.addRemote(name, url)); } diff --git a/extensions/git/src/typings/git-base.d.ts b/extensions/git/src/typings/git-base.d.ts index 1eeb1739901..37dd2c4229c 100644 --- a/extensions/git/src/typings/git-base.d.ts +++ b/extensions/git/src/typings/git-base.d.ts @@ -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; - getRemoteSourceActions(url: string): Promise; registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + getRemoteSourceActions(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; + pickRemoteSource(options: PickRemoteSourceOptions): Promise; } export interface GitBaseExtension { @@ -81,6 +82,7 @@ export interface RemoteSourceProvider { getBranches?(url: string): ProviderResult; getRemoteSourceActions?(url: string): ProviderResult; + getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; } diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 1f1504521f8..2fc47e096f6 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -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 { @@ -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); })); diff --git a/extensions/github/src/links.ts b/extensions/github/src/links.ts index 911f0e5376b..fe97d172249 100644 --- a/extensions/github/src/links.ts +++ b/extensions/github/src/links.ts @@ -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`; } diff --git a/extensions/github/src/remoteSourceProvider.ts b/extensions/github/src/remoteSourceProvider.ts index 0d8b9340695..0c2ef166832 100644 --- a/extensions/github/src/remoteSourceProvider.ts +++ b/extensions/github/src/remoteSourceProvider.ts @@ -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'; @@ -136,4 +136,18 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { } }]; } + + async getRemoteSourceControlHistoryItemCommands(url: string): Promise { + 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] + }]; + } } diff --git a/extensions/github/src/typings/git-base.d.ts b/extensions/github/src/typings/git-base.d.ts index 53cac4d5c70..37dd2c4229c 100644 --- a/extensions/github/src/typings/git-base.d.ts +++ b/extensions/github/src/typings/git-base.d.ts @@ -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; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; } @@ -80,6 +82,7 @@ export interface RemoteSourceProvider { getBranches?(url: string): ProviderResult; getRemoteSourceActions?(url: string): ProviderResult; + getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; }