diff --git a/extensions/git-base/src/api/api1.ts b/extensions/git-base/src/api/api1.ts index 74edc7f4452..2c668945211 100644 --- a/extensions/git-base/src/api/api1.ts +++ b/extensions/git-base/src/api/api1.ts @@ -5,7 +5,7 @@ import { Command, Disposable, commands } from 'vscode'; import { Model } from '../model'; -import { getRemoteSourceActions, getRemoteSourceControlHistoryItemCommands, pickRemoteSource } from '../remoteSource'; +import { getRemoteSourceActions, getRemoteSourceControlHistoryItemCommands, pickRemoteSource, provideRemoteSourceLinks } from '../remoteSource'; import { GitBaseExtensionImpl } from './extension'; import { API, PickRemoteSourceOptions, PickRemoteSourceResult, RemoteSourceAction, RemoteSourceProvider } from './git-base'; @@ -21,10 +21,14 @@ export class ApiImpl implements API { return getRemoteSourceActions(this._model, url); } - getRemoteSourceControlHistoryItemCommands(url: string): Promise { + getRemoteSourceControlHistoryItemCommands(url: string): Promise { return getRemoteSourceControlHistoryItemCommands(this._model, url); } + provideRemoteSourceLinks(url: string, content: string): Promise { + return provideRemoteSourceLinks(this._model, url, content); + } + 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 37dd2c4229c..540aa583146 100644 --- a/extensions/git-base/src/api/git-base.d.ts +++ b/extensions/git-base/src/api/git-base.d.ts @@ -9,7 +9,8 @@ export { ProviderResult } from 'vscode'; export interface API { registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; getRemoteSourceActions(url: string): Promise; - getRemoteSourceControlHistoryItemCommands(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; + provideRemoteSourceLinks(url: string, content: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; } @@ -85,4 +86,5 @@ export interface RemoteSourceProvider { getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; + provideRemoteSourceLinks?(url: string, content: string): Promise; } diff --git a/extensions/git-base/src/remoteSource.ts b/extensions/git-base/src/remoteSource.ts index 8d8d4ab102f..cf570e3aa00 100644 --- a/extensions/git-base/src/remoteSource.ts +++ b/extensions/git-base/src/remoteSource.ts @@ -123,18 +123,30 @@ export async function getRemoteSourceActions(model: Model, url: string): Promise return remoteSourceActions; } -export async function getRemoteSourceControlHistoryItemCommands(model: Model, url: string): Promise { +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); - } + remoteSourceCommands.push(...(await provider.getRemoteSourceControlHistoryItemCommands?.(url) ?? [])); } - return remoteSourceCommands; + return remoteSourceCommands.length > 0 ? remoteSourceCommands : undefined; +} + +export async function provideRemoteSourceLinks(model: Model, url: string, content: string): Promise { + const providers = model.getRemoteProviders(); + + for (const provider of providers) { + const parsedContent = await provider.provideRemoteSourceLinks?.(url, content); + if (!parsedContent) { + continue; + } + + content = parsedContent; + } + + return content; } export async function pickRemoteSource(model: Model, options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; diff --git a/extensions/git/src/blame.ts b/extensions/git/src/blame.ts index 9ab8e3e58bd..6f9d0cc88e4 100644 --- a/extensions/git/src/blame.ts +++ b/extensions/git/src/blame.ts @@ -12,7 +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'; +import { getRemoteSourceControlHistoryItemCommands, provideRemoteSourceLinks } from './remoteSource'; function lineRangesContainLine(changes: readonly TextEditorChange[], lineNumber: number): boolean { return changes.some(c => c.modified.startLineNumber <= lineNumber && lineNumber < c.modified.endLineNumberExclusive); @@ -205,6 +205,7 @@ export class GitBlameController { async getBlameInformationHover(documentUri: Uri, blameInformation: BlameInformation, includeCommitDetails = false): Promise { let commitInformation: Commit | undefined; + let commitMessageWithLinks: string | undefined; const remoteSourceCommands: Command[] = []; const repository = this._model.getRepository(documentUri); @@ -217,12 +218,15 @@ export class GitBlameController { } // Remote commands - const defaultRemote = repository.getDefaultRemote(); const unpublishedCommits = await repository.getUnpublishedCommits(); - - if (defaultRemote?.fetchUrl && !unpublishedCommits.has(blameInformation.hash)) { - remoteSourceCommands.push(...await getRemoteSourceControlHistoryItemCommands(defaultRemote.fetchUrl)); + if (!unpublishedCommits.has(blameInformation.hash)) { + remoteSourceCommands.push(...await getRemoteSourceControlHistoryItemCommands(repository)); } + + // Link provider + commitMessageWithLinks = await provideRemoteSourceLinks( + repository, + commitInformation?.message ?? blameInformation.subject ?? ''); } const markdownString = new MarkdownString(); @@ -254,7 +258,7 @@ export class GitBlameController { } // Subject | Message - markdownString.appendMarkdown(`${emojify(commitInformation?.message ?? blameInformation.subject ?? '')}\n\n`); + markdownString.appendMarkdown(`${emojify(commitMessageWithLinks ?? commitInformation?.message ?? blameInformation.subject ?? '')}\n\n`); markdownString.appendMarkdown(`---\n\n`); // Short stats diff --git a/extensions/git/src/historyProvider.ts b/extensions/git/src/historyProvider.ts index 6d65c5226b6..bda106f46d5 100644 --- a/extensions/git/src/historyProvider.ts +++ b/extensions/git/src/historyProvider.ts @@ -12,6 +12,7 @@ import { Branch, LogOptions, Ref, RefType } from './api/git'; import { emojify, ensureEmojis } from './emoji'; import { Commit } from './git'; import { OperationKind, OperationResult } from './operation'; +import { provideRemoteSourceLinks } from './remoteSource'; function toSourceControlHistoryItemRef(repository: Repository, ref: Ref): SourceControlHistoryItemRef { const rootUri = Uri.file(repository.root); @@ -264,22 +265,33 @@ export class GitHistoryProvider implements SourceControlHistoryProvider, FileDec await ensureEmojis(); - return commits.map(commit => { + const historyItems: SourceControlHistoryItem[] = []; + for (const commit of commits) { + const message = emojify(commit.message); + const messageWithLinks = await provideRemoteSourceLinks(this.repository, message) ?? message; + + const newLineIndex = message.indexOf('\n'); + const subject = newLineIndex !== -1 + ? `${message.substring(0, newLineIndex)}\u2026` + : message; + const references = this._resolveHistoryItemRefs(commit); - return { + historyItems.push({ id: commit.hash, parentIds: commit.parents, - message: emojify(commit.message), + subject, + message: messageWithLinks, author: commit.authorName, authorEmail: commit.authorEmail, - icon: new ThemeIcon('git-commit'), displayId: getCommitShortHash(Uri.file(this.repository.root), commit.hash), timestamp: commit.authorDate?.getTime(), statistics: commit.shortStat ?? { files: 0, insertions: 0, deletions: 0 }, references: references.length !== 0 ? references : undefined - }; - }); + } satisfies SourceControlHistoryItem); + } + + return historyItems; } catch (err) { this.logger.error(`[GitHistoryProvider][provideHistoryItems] Failed to get history items with options '${JSON.stringify(options)}': ${err}`); return []; diff --git a/extensions/git/src/remoteSource.ts b/extensions/git/src/remoteSource.ts index dfdb36fc11f..86daccedf95 100644 --- a/extensions/git/src/remoteSource.ts +++ b/extensions/git/src/remoteSource.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Command } from 'vscode'; import { PickRemoteSourceOptions, PickRemoteSourceResult } from './typings/git-base'; import { GitBaseApi } from './git-base'; +import { Repository } from './repository'; export async function pickRemoteSource(options: PickRemoteSourceOptions & { branch?: false | undefined }): Promise; export async function pickRemoteSource(options: PickRemoteSourceOptions & { branch: true }): Promise; @@ -16,6 +18,35 @@ export async function getRemoteSourceActions(url: string) { return GitBaseApi.getAPI().getRemoteSourceActions(url); } -export async function getRemoteSourceControlHistoryItemCommands(url: string) { - return GitBaseApi.getAPI().getRemoteSourceControlHistoryItemCommands(url); +export async function getRemoteSourceControlHistoryItemCommands(repository: Repository): Promise { + if (repository.remotes.length === 0) { + return []; + } + + const getCommands = async (repository: Repository, remoteName: string): Promise => { + const remote = repository.remotes.find(r => r.name === remoteName && r.fetchUrl); + return remote ? GitBaseApi.getAPI().getRemoteSourceControlHistoryItemCommands(remote.fetchUrl!) : undefined; + }; + + // upstream -> origin -> first + return await getCommands(repository, 'upstream') + ?? await getCommands(repository, 'origin') + ?? await getCommands(repository, repository.remotes[0].name) + ?? []; +} + +export async function provideRemoteSourceLinks(repository: Repository, content: string): Promise { + if (repository.remotes.length === 0) { + return undefined; + } + + const getDocumentLinks = async (repository: Repository, remoteName: string): Promise => { + const remote = repository.remotes.find(r => r.name === remoteName && r.fetchUrl); + return remote ? GitBaseApi.getAPI().provideRemoteSourceLinks(remote.fetchUrl!, content) : undefined; + }; + + // upstream -> origin -> first + return await getDocumentLinks(repository, 'upstream') + ?? await getDocumentLinks(repository, 'origin') + ?? await getDocumentLinks(repository, repository.remotes[0].name); } diff --git a/extensions/git/src/timelineProvider.ts b/extensions/git/src/timelineProvider.ts index b243d72ed4b..90d202cc415 100644 --- a/extensions/git/src/timelineProvider.ts +++ b/extensions/git/src/timelineProvider.ts @@ -12,7 +12,7 @@ import { CommandCenter } from './commands'; import { OperationKind, OperationResult } from './operation'; import { getCommitShortHash } from './util'; import { CommitShortStat } from './git'; -import { getRemoteSourceControlHistoryItemCommands } from './remoteSource'; +import { getRemoteSourceControlHistoryItemCommands, provideRemoteSourceLinks } from './remoteSource'; export class GitTimelineItem extends TimelineItem { static is(item: TimelineItem): item is GitTimelineItem { @@ -215,25 +215,27 @@ export class GitTimelineProvider implements TimelineProvider { const openComparison = l10n.t('Open Comparison'); - const defaultRemote = repo.getDefaultRemote(); const unpublishedCommits = await repo.getUnpublishedCommits(); - const remoteSourceCommands: Command[] = defaultRemote?.fetchUrl - ? await getRemoteSourceControlHistoryItemCommands(defaultRemote.fetchUrl) - : []; + const remoteSourceCommands = await getRemoteSourceControlHistoryItemCommands(repo); + + const items: GitTimelineItem[] = []; + for (let index = 0; index < commits.length; index++) { + const c = commits[index]; - const items = commits.map((c, i) => { const date = dateType === 'authored' ? c.authorDate : c.commitDate; const message = emojify(c.message); - const item = new GitTimelineItem(c.hash, commits[i + 1]?.hash ?? `${c.hash}^`, message, date?.getTime() ?? 0, c.hash, 'git:file:commit'); + const item = new GitTimelineItem(c.hash, commits[index + 1]?.hash ?? `${c.hash}^`, message, date?.getTime() ?? 0, c.hash, 'git:file:commit'); item.iconPath = new ThemeIcon('git-commit'); if (showAuthor) { item.description = c.authorName; } const commitRemoteSourceCommands = !unpublishedCommits.has(c.hash) ? remoteSourceCommands : []; - item.setItemDetails(uri, c.hash, c.authorName!, c.authorEmail, dateFormatter.format(date), message, c.shortStat, commitRemoteSourceCommands); + const messageWithLinks = await provideRemoteSourceLinks(repo, message) ?? message; + + item.setItemDetails(uri, c.hash, c.authorName!, c.authorEmail, dateFormatter.format(date), messageWithLinks, c.shortStat, commitRemoteSourceCommands); const cmd = this.commands.resolveTimelineOpenDiffCommand(item, uri); if (cmd) { @@ -244,8 +246,8 @@ export class GitTimelineProvider implements TimelineProvider { }; } - return item; - }); + items.push(item); + } if (options.cursor === undefined) { const you = l10n.t('You'); diff --git a/extensions/git/src/typings/git-base.d.ts b/extensions/git/src/typings/git-base.d.ts index 37dd2c4229c..3b61341d806 100644 --- a/extensions/git/src/typings/git-base.d.ts +++ b/extensions/git/src/typings/git-base.d.ts @@ -9,8 +9,9 @@ export { ProviderResult } from 'vscode'; export interface API { registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; getRemoteSourceActions(url: string): Promise; - getRemoteSourceControlHistoryItemCommands(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; + provideRemoteSourceLinks(url: string, content: string): Promise; } export interface GitBaseExtension { diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 463b91c1972..47b6aea454b 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -85,10 +85,12 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return; } - // Default remote (origin, or the first remote) - const defaultRemote = remotes.find(r => r.name === 'origin') ?? remotes[0]; + // Default remote (upstream -> origin -> first) + const remote = remotes.find(r => r.name === 'upstream') + ?? remotes.find(r => r.name === 'origin') + ?? remotes[0]; - const link = getCommitLink(defaultRemote.fetchUrl!, historyItem.id); + const link = getCommitLink(remote.fetchUrl!, historyItem.id); vscode.env.openExternal(vscode.Uri.parse(link)); })); diff --git a/extensions/github/src/remoteSourceProvider.ts b/extensions/github/src/remoteSourceProvider.ts index 0c2ef166832..e79931c7415 100644 --- a/extensions/github/src/remoteSourceProvider.ts +++ b/extensions/github/src/remoteSourceProvider.ts @@ -7,7 +7,7 @@ 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'; -import { getRepositoryFromQuery, getRepositoryFromUrl } from './util'; +import { getRepositoryFromQuery, getRepositoryFromUrl, ISSUE_EXPRESSION } from './util'; import { getBranchLink, getVscodeDevHost } from './links'; function asRemoteSource(raw: any): RemoteSource { @@ -137,10 +137,10 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { }]; } - async getRemoteSourceControlHistoryItemCommands(url: string): Promise { + async getRemoteSourceControlHistoryItemCommands(url: string): Promise { const repository = getRepositoryFromUrl(url); if (!repository) { - return []; + return undefined; } return [{ @@ -150,4 +150,28 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider { arguments: [url] }]; } + + provideRemoteSourceLinks(url: string, content: string): string | undefined { + const repository = getRepositoryFromUrl(url); + if (!repository) { + return undefined; + } + + return content.replace( + ISSUE_EXPRESSION, + (match, _group1, owner: string | undefined, repo: string | undefined, _group2, number: string | undefined) => { + if (!number || Number.isNaN(parseInt(number))) { + return match; + } + + const label = owner && repo + ? `${owner}/${repo}#${number}` + : `#${number}`; + + owner = owner ?? repository.owner; + repo = repo ?? repository.repo; + + return `[${label}](https://github.com/${owner}/${repo}/issues/${number})`; + }); + } } diff --git a/extensions/github/src/typings/git-base.d.ts b/extensions/github/src/typings/git-base.d.ts index 37dd2c4229c..548369b1f0f 100644 --- a/extensions/github/src/typings/git-base.d.ts +++ b/extensions/github/src/typings/git-base.d.ts @@ -9,8 +9,9 @@ export { ProviderResult } from 'vscode'; export interface API { registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; getRemoteSourceActions(url: string): Promise; - getRemoteSourceControlHistoryItemCommands(url: string): Promise; + getRemoteSourceControlHistoryItemCommands(url: string): Promise; pickRemoteSource(options: PickRemoteSourceOptions): Promise; + provideRemoteSourceLinks(url: string, content: string): ProviderResult; } export interface GitBaseExtension { @@ -85,4 +86,5 @@ export interface RemoteSourceProvider { getRemoteSourceControlHistoryItemCommands?(url: string): ProviderResult; getRecentRemoteSources?(query?: string): ProviderResult; getRemoteSources(query?: string): ProviderResult; + provideRemoteSourceLinks?(url: string, content: string): ProviderResult; } diff --git a/extensions/github/src/util.ts b/extensions/github/src/util.ts index 3d8bf4a40be..5289bb93181 100644 --- a/extensions/github/src/util.ts +++ b/extensions/github/src/util.ts @@ -37,3 +37,5 @@ export function getRepositoryFromQuery(query: string): { owner: string; repo: st export function repositoryHasGitHubRemote(repository: Repository) { return !!repository.state.remotes.find(remote => remote.fetchUrl ? getRepositoryFromUrl(remote.fetchUrl) : undefined); } + +export const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/g; diff --git a/src/vs/workbench/api/browser/mainThreadSCM.ts b/src/vs/workbench/api/browser/mainThreadSCM.ts index 5b22d1f069f..2157d1ef577 100644 --- a/src/vs/workbench/api/browser/mainThreadSCM.ts +++ b/src/vs/workbench/api/browser/mainThreadSCM.ts @@ -49,11 +49,7 @@ function toISCMHistoryItem(historyItemDto: SCMHistoryItemDto): ISCMHistoryItem { ...r, icon: getIconFromIconDto(r.icon) })); - const newLineIndex = historyItemDto.message.indexOf('\n'); - const subject = newLineIndex === -1 ? - historyItemDto.message : `${historyItemDto.message.substring(0, newLineIndex)}\u2026`; - - return { ...historyItemDto, subject, references }; + return { ...historyItemDto, references }; } function toISCMHistoryItemRef(historyItemRefDto?: SCMHistoryItemRefDto, color?: ColorIdentifier): ISCMHistoryItemRef | undefined { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index e8de9400531..478f2d6669c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1603,6 +1603,7 @@ export interface SCMHistoryItemRefsChangeEventDto { export interface SCMHistoryItemDto { readonly id: string; readonly parentIds: string[]; + readonly subject: string; readonly message: string; readonly displayId?: string; readonly author?: string; diff --git a/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts b/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts index 8ab6b7cbd66..0c888bdfa9f 100644 --- a/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.scmHistoryProvider.d.ts @@ -48,6 +48,7 @@ declare module 'vscode' { export interface SourceControlHistoryItem { readonly id: string; readonly parentIds: string[]; + readonly subject: string; readonly message: string; readonly displayId?: string; readonly author?: string;