diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index 1e689c1056..aee8d38ee6 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { basename } from 'path'; import * as vscode from 'vscode'; import { Remote } from '../api/api'; import { GitApiImpl } from '../api/api1'; @@ -54,6 +55,7 @@ import { UserHoverProvider } from './userHoverProvider'; import { createGitHubLink, createGithubPermalink, + createSinglePermalink, getIssue, IssueTemplate, LinkContext, @@ -137,12 +139,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubPermalink', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyGithubPermalink" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubPermalink'); - return this.copyPermalink(this.manager, context); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context]); }, this, ), @@ -150,12 +152,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubHeadLink', - (fileUri: any) => { + (fileUri: vscode.Uri, additional: vscode.Uri[] | undefined) => { /* __GDPR__ "issue.copyGithubHeadLink" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLink'); - return this.copyHeadLink(fileUri); + return this.copyHeadLink(additional && additional.length > 0 ? additional : [fileUri]); }, this, ), @@ -163,12 +165,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubPermalinkWithoutRange', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyGithubPermalinkWithoutRange" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubPermalinkWithoutRange'); - return this.copyPermalink(this.manager, context, false); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false); }, this, ), @@ -176,12 +178,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubHeadLinkWithoutRange', - (fileUri: any) => { + (fileUri: vscode.Uri, additional: vscode.Uri[] | undefined) => { /* __GDPR__ "issue.copyGithubHeadLinkWithoutRange" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubHeadLinkWithoutRange'); - return this.copyHeadLink(fileUri, false); + return this.copyHeadLink(additional && additional.length > 0 ? additional : [fileUri], false); }, this, ), @@ -189,12 +191,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubDevLinkWithoutRange', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyGithubDevLinkWithoutRange" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkWithoutRange'); - return this.copyPermalink(this.manager, context, false, true, true); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false, true, true); }, this, ), @@ -202,12 +204,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubDevLink', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyGithubDevLink" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubDevLink'); - return this.copyPermalink(this.manager, context, true, true, true); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], true, true, true); }, this, ), @@ -215,12 +217,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyGithubDevLinkFile', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyGithubDevLinkFile" : {} */ this.telemetry.sendTelemetryEvent('issue.copyGithubDevLinkFile'); - return this.copyPermalink(this.manager, context, false, true, true); + return this.copyPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false, true, true); }, this, ), @@ -228,12 +230,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyMarkdownGithubPermalink', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyMarkdownGithubPermalink" : {} */ this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalink'); - return this.copyMarkdownPermalink(this.manager, context); + return this.copyMarkdownPermalink(this.manager, additional && additional.length > 0 ? additional : [context]); }, this, ), @@ -241,12 +243,12 @@ export class IssueFeatureRegistrar extends Disposable { this._register( vscode.commands.registerCommand( 'issue.copyMarkdownGithubPermalinkWithoutRange', - (context: LinkContext) => { + (context: LinkContext, additional: LinkContext[] | undefined) => { /* __GDPR__ "issue.copyMarkdownGithubPermalinkWithoutRange" : {} */ this.telemetry.sendTelemetryEvent('issue.copyMarkdownGithubPermalinkWithoutRange'); - return this.copyMarkdownPermalink(this.manager, context, false); + return this.copyMarkdownPermalink(this.manager, additional && additional.length > 0 ? additional : [context], false); }, this, ), @@ -949,7 +951,7 @@ export class IssueFeatureRegistrar extends Disposable { } } - contents += (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + contents += (await createSinglePermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; return contents; } @@ -1293,7 +1295,7 @@ ${options?.body ?? ''}\n const body: string | undefined = issueBody || newIssue?.document.isUntitled ? issueBody - : (await createGithubPermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; + : (await createSinglePermalink(this.manager, this.gitAPI, true, true, newIssue)).permalink; const createParams: OctokitCommon.IssuesCreateParams = { owner: origin.owner, repo: origin.repo, @@ -1343,20 +1345,24 @@ ${options?.body ?? ''}\n }); } - private async getPermalinkWithError(repositoriesManager: RepositoriesManager, includeRange: boolean, includeFile: boolean, context?: LinkContext): Promise { - const link = await createGithubPermalink(repositoriesManager, this.gitAPI, includeRange, includeFile, undefined, context); - if (link.error) { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', link.error)); + private async getPermalinkWithError(repositoriesManager: RepositoriesManager, includeRange: boolean, includeFile: boolean, context?: LinkContext[]): Promise { + const links = await createGithubPermalink(repositoriesManager, this.gitAPI, includeRange, includeFile, undefined, context); + const firstError = links.find(link => link.error); + if (firstError) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub permalink for the selection. {0}', firstError.error!)); } - return link; + return links; } - private async getHeadLinkWithError(context?: vscode.Uri, includeRange?: boolean): Promise { - const link = await createGitHubLink(this.manager, context, includeRange); - if (link.error) { - vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', link.error)); + private async getHeadLinkWithError(context?: vscode.Uri[], includeRange?: boolean): Promise { + const links = await createGitHubLink(this.manager, context, includeRange); + if (links.length > 0) { + const firstError = links.find(link => link.error); + if (firstError) { + vscode.window.showWarningMessage(vscode.l10n.t('Unable to create a GitHub link for the selection. {0}', firstError.error!)); + } } - return link; + return links; } private async getContextualizedLink(file: vscode.Uri, link: string): Promise { @@ -1376,19 +1382,30 @@ ${options?.body ?? ''}\n return linkUri.with({ authority, path: linkPath }).toString(); } - async copyPermalink(repositoriesManager: RepositoriesManager, context?: LinkContext, includeRange: boolean = true, includeFile: boolean = true, contextualizeLink: boolean = false) { - const link = await this.getPermalinkWithError(repositoriesManager, includeRange, includeFile, context); - if (link.permalink) { - const contextualizedLink = contextualizeLink && link.originalFile ? await this.getContextualizedLink(link.originalFile, link.permalink) : link.permalink; - Logger.debug(`writing ${contextualizedLink} to the clipboard`, PERMALINK_COMPONENT); - return vscode.env.clipboard.writeText(contextualizedLink); + private async permalinkInfoToClipboardText(links: PermalinkInfo[], shouldContextualize: boolean = false): Promise { + const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); + if (withPermalinks.length !== 0) { + const contextualizedLinks = await Promise.all(withPermalinks.map(async link => (shouldContextualize && link.originalFile) ? await this.getContextualizedLink(link.originalFile, link.permalink) : link.permalink)); + const clipboardText = contextualizedLinks.join('\n'); + Logger.debug(`Will write ${clipboardText} to the clipboard`, PERMALINK_COMPONENT); + return clipboardText; + } + return undefined; + } + + async copyPermalink(repositoriesManager: RepositoriesManager, context?: LinkContext[], includeRange: boolean = true, includeFile: boolean = true, contextualizeLink: boolean = false) { + const links = await this.getPermalinkWithError(repositoriesManager, includeRange, includeFile, context); + const clipboardText = await this.permalinkInfoToClipboardText(links, contextualizeLink); + if (clipboardText) { + return vscode.env.clipboard.writeText(clipboardText); } } - async copyHeadLink(fileUri?: vscode.Uri, includeRange = true) { + async copyHeadLink(fileUri?: vscode.Uri[], includeRange = true) { const link = await this.getHeadLinkWithError(fileUri, includeRange); - if (link.permalink) { - return vscode.env.clipboard.writeText(link.permalink); + const clipboardText = await this.permalinkInfoToClipboardText(link); + if (clipboardText) { + return vscode.env.clipboard.writeText(clipboardText); } } @@ -1414,18 +1431,27 @@ ${options?.body ?? ''}\n return undefined; } - async copyMarkdownPermalink(repositoriesManager: RepositoriesManager, context: LinkContext, includeRange: boolean = true) { - const link = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); - const selection = this.getMarkdownLinkText(); - if (link.permalink && selection) { - return vscode.env.clipboard.writeText(`[${selection.trim()}](${link.permalink})`); + async copyMarkdownPermalink(repositoriesManager: RepositoriesManager, context: LinkContext[], includeRange: boolean = true) { + const links = await this.getPermalinkWithError(repositoriesManager, includeRange, true, context); + const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); + + if (withPermalinks.length === 1) { + const selection = this.getMarkdownLinkText(); + if (selection) { + return vscode.env.clipboard.writeText(`[${selection.trim()}](${withPermalinks[0].permalink})`); + } } + const clipboardText = withPermalinks.map(link => `[${basename(link.originalFile?.fsPath ?? '')}](${link.permalink})`).join('\n'); + Logger.debug(`writing ${clipboardText} to the clipboard`, PERMALINK_COMPONENT); + return vscode.env.clipboard.writeText(clipboardText); } async openPermalink(repositoriesManager: RepositoriesManager) { - const link = await this.getPermalinkWithError(repositoriesManager, true, true); - if (link.permalink) { - return vscode.env.openExternal(vscode.Uri.parse(link.permalink)); + const links = await this.getPermalinkWithError(repositoriesManager, true, true); + const withPermalinks: (PermalinkInfo & { permalink: string })[] = links.filter((link): link is PermalinkInfo & { permalink: string } => !!link.permalink); + + if (withPermalinks.length > 0) { + return vscode.env.openExternal(vscode.Uri.parse(withPermalinks[0].permalink)); } return undefined; } diff --git a/src/issues/util.ts b/src/issues/util.ts index 37b47c77d6..f59716ba57 100644 --- a/src/issues/util.ts +++ b/src/issues/util.ts @@ -485,7 +485,7 @@ export function getOwnerAndRepo(repositoriesManager: RepositoriesManager, reposi } } -export async function createGithubPermalink( +export async function createSinglePermalink( repositoriesManager: RepositoriesManager, gitAPI: GitApiImpl, includeRange: boolean, @@ -493,60 +493,79 @@ export async function createGithubPermalink( positionInfo?: NewIssue, context?: LinkContext ): Promise { - return vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, async (progress) => { - progress.report({ message: vscode.l10n.t('Creating permalink...') }); - const { uri, range } = getFileAndPosition(context, positionInfo); - if (!uri) { - return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined }; - } + const { uri, range } = getFileAndPosition(context, positionInfo); + if (!uri) { + return { permalink: undefined, error: vscode.l10n.t('No active text editor position to create permalink from.'), originalFile: undefined }; + } - const repository = getRepositoryForFile(gitAPI, uri); - if (!repository) { - return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri }; - } + const repository = getRepositoryForFile(gitAPI, uri); + if (!repository) { + return { permalink: undefined, error: vscode.l10n.t('The current file isn\'t part of repository.'), originalFile: uri }; + } - let commitHash: string | undefined; - if (uri.scheme === Schemes.Review) { - commitHash = fromReviewUri(uri.query).commit; - } + let commitHash: string | undefined; + if (uri.scheme === Schemes.Review) { + commitHash = fromReviewUri(uri.query).commit; + } - if (!commitHash) { - try { - const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); - if (log.length === 0) { - return { permalink: undefined, error: vscode.l10n.t('No branch on a remote contains the most recent commit for the file.'), originalFile: uri }; - } - // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. - if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { - commitHash = repository.state.HEAD.commit; - } else { - commitHash = log[0].hash; - } - } catch (e) { - commitHash = repository.state.HEAD?.commit; + if (!commitHash) { + try { + const log = await repository.log({ maxEntries: 1, path: uri.fsPath }); + if (log.length === 0) { + return { permalink: undefined, error: vscode.l10n.t('No branch on a remote contains the most recent commit for the file.'), originalFile: uri }; + } + // Now that we know that the file existed at some point in the repo, use the head commit to construct the URI. + if (repository.state.HEAD?.commit && (log[0].hash !== repository.state.HEAD?.commit)) { + commitHash = repository.state.HEAD.commit; + } else { + commitHash = log[0].hash; } + } catch (e) { + commitHash = repository.state.HEAD?.commit; } + } - Logger.debug(`commit hash: ${commitHash}`, PERMALINK_COMPONENT); + Logger.debug(`commit hash: ${commitHash}`, PERMALINK_COMPONENT); - const rawUpstream = await getBestPossibleUpstream(repositoriesManager, repository, commitHash); - if (!rawUpstream || !rawUpstream.fetchUrl) { - return { permalink: undefined, error: vscode.l10n.t('The selection may not exist on any remote.'), originalFile: uri }; - } - const upstream: Remote & { fetchUrl: string } = rawUpstream as any; - - Logger.debug(`upstream: ${upstream.fetchUrl}`, PERMALINK_COMPONENT); - - const encodedPathSegment = encodeURIComponentExceptSlashes(uri.path.substring(repository.rootUri.path.length)); - const originOfFetchUrl = getUpstreamOrigin(rawUpstream).replace(/\/$/, ''); - const result = { - permalink: (`${originOfFetchUrl}/${getOwnerAndRepo(repositoriesManager, repository, upstream)}/blob/${commitHash - }${includeFile ? `${encodedPathSegment}${includeRange ? rangeString(range) : ''}` : ''}`), - error: undefined, - originalFile: uri - }; - Logger.debug(`permalink generated: ${result.permalink}`, PERMALINK_COMPONENT); - return result; + const rawUpstream = await getBestPossibleUpstream(repositoriesManager, repository, commitHash); + if (!rawUpstream || !rawUpstream.fetchUrl) { + return { permalink: undefined, error: vscode.l10n.t('The selection may not exist on any remote.'), originalFile: uri }; + } + const upstream: Remote & { fetchUrl: string } = rawUpstream as any; + + Logger.debug(`upstream: ${upstream.fetchUrl}`, PERMALINK_COMPONENT); + + const encodedPathSegment = encodeURIComponentExceptSlashes(uri.path.substring(repository.rootUri.path.length)); + const originOfFetchUrl = getUpstreamOrigin(rawUpstream).replace(/\/$/, ''); + const result = { + permalink: (`${originOfFetchUrl}/${getOwnerAndRepo(repositoriesManager, repository, upstream)}/blob/${commitHash + }${includeFile ? `${encodedPathSegment}${includeRange ? rangeString(range) : ''}` : ''}`), + error: undefined, + originalFile: uri + }; + Logger.debug(`permalink generated: ${result.permalink}`, PERMALINK_COMPONENT); + return result; +} + +export async function createGithubPermalink( + repositoriesManager: RepositoriesManager, + gitAPI: GitApiImpl, + includeRange: boolean, + includeFile: boolean, + positionInfo?: NewIssue, + contexts?: LinkContext[] +): Promise { + return vscode.window.withProgress({ location: vscode.ProgressLocation.Window }, async (progress) => { + progress.report({ message: vscode.l10n.t('Creating permalink...') }); + let contextIndex = 0; + let context: LinkContext | undefined = contexts ? contexts[contextIndex++] : undefined; + const links: Promise[] = []; + do { + links.push(createSinglePermalink(repositoriesManager, gitAPI, includeRange, includeFile, positionInfo, context)); + context = contexts ? contexts[contextIndex++] : undefined; + } while (context); + + return Promise.all(links); }); } @@ -602,9 +621,9 @@ interface EditorLineNumberContext { } export type LinkContext = vscode.Uri | EditorLineNumberContext | undefined; -export async function createGitHubLink( +export async function createSingleGitHubLink( managers: RepositoriesManager, - context: LinkContext, + context?: vscode.Uri, includeRange?: boolean ): Promise { const { uri, range } = getFileAndPosition(context); @@ -637,6 +656,22 @@ export async function createGitHubLink( }; } +export async function createGitHubLink( + managers: RepositoriesManager, + contexts?: vscode.Uri[], + includeRange?: boolean +): Promise { + let contextIndex = 0; + let context: vscode.Uri | undefined = contexts ? contexts[contextIndex++] : undefined; + const links: Promise[] = []; + do { + links.push(createSingleGitHubLink(managers, context, includeRange)); + context = contexts ? contexts[contextIndex++] : undefined; + } while (context); + + return Promise.all(links); +} + async function commitWithDefault(manager: FolderRepositoryManager, stateManager: StateManager, all: boolean) { const message = await stateManager.currentIssue(manager.repository.rootUri)?.getCommitMessage(); if (message) {