From a28d5739cbe2133152ba58f1b4c84e76dd71de8a Mon Sep 17 00:00:00 2001 From: Michael Hutchison Date: Sun, 18 Apr 2021 15:21:21 +1000 Subject: [PATCH] #499 If a branch's name contains an issue number, the issue can be viewed via the branch's context menu. --- package.json | 8 +++++ src/config.ts | 4 +-- src/types.ts | 2 ++ tests/config.test.ts | 6 ++++ web/main.ts | 32 ++++++++++++++++++++ web/settingsWidget.ts | 12 ++++---- web/textFormatter.ts | 68 +++++++++++++++++++++++++++++++------------ 7 files changed, 105 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 24abc04b..4b1c6d15 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,10 @@ "type": "boolean", "title": "Push Branch..." }, + "viewIssue": { + "type": "boolean", + "title": "View Issue" + }, "createPullRequest": { "type": "boolean", "title": "Create Pull Request..." @@ -263,6 +267,10 @@ "type": "boolean", "title": "Pull into current branch..." }, + "viewIssue": { + "type": "boolean", + "title": "View Issue" + }, "createPullRequest": { "type": "boolean", "title": "Create Pull Request" diff --git a/src/config.ts b/src/config.ts index d5dc870b..88a05740 100644 --- a/src/config.ts +++ b/src/config.ts @@ -81,9 +81,9 @@ class Config { get contextMenuActionsVisibility(): ContextMenuActionsVisibility { const userConfig = this.config.get('contextMenuActionsVisibility', {}); const config: ContextMenuActionsVisibility = { - branch: { checkout: true, rename: true, delete: true, merge: true, rebase: true, push: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true }, + branch: { checkout: true, rename: true, delete: true, merge: true, rebase: true, push: true, viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true }, commit: { addTag: true, createBranch: true, checkout: true, cherrypick: true, revert: true, drop: true, merge: true, rebase: true, reset: true, copyHash: true, copySubject: true }, - remoteBranch: { checkout: true, delete: true, fetch: true, merge: true, pull: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true }, + remoteBranch: { checkout: true, delete: true, fetch: true, merge: true, pull: true, viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, unselectInBranchesDropdown: true, copyName: true }, stash: { apply: true, createBranch: true, pop: true, drop: true, copyName: true, copyHash: true }, tag: { viewDetails: true, delete: true, push: true, createArchive: true, copyName: true }, uncommittedChanges: { stash: true, reset: true, clean: true, openSourceControlView: true } diff --git a/src/types.ts b/src/types.ts index 70004b0d..8410deae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -348,6 +348,7 @@ export interface ContextMenuActionsVisibility { readonly merge: boolean; readonly rebase: boolean; readonly push: boolean; + readonly viewIssue: boolean; readonly createPullRequest: boolean; readonly createArchive: boolean; readonly selectInBranchesDropdown: boolean; @@ -373,6 +374,7 @@ export interface ContextMenuActionsVisibility { readonly fetch: boolean; readonly merge: boolean; readonly pull: boolean; + readonly viewIssue: boolean; readonly createPullRequest: boolean; readonly createArchive: boolean; readonly selectInBranchesDropdown: boolean; diff --git a/tests/config.test.ts b/tests/config.test.ts index 18cb70cd..1405936b 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -270,6 +270,7 @@ describe('Config', () => { merge: true, rebase: true, push: true, + viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, @@ -295,6 +296,7 @@ describe('Config', () => { fetch: true, merge: true, pull: true, + viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, @@ -339,6 +341,7 @@ describe('Config', () => { merge: true, rebase: true, push: true, + viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, @@ -364,6 +367,7 @@ describe('Config', () => { fetch: true, merge: true, pull: true, + viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, @@ -423,6 +427,7 @@ describe('Config', () => { merge: true, rebase: true, push: true, + viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, @@ -448,6 +453,7 @@ describe('Config', () => { fetch: false, merge: true, pull: true, + viewIssue: true, createPullRequest: true, createArchive: true, selectInBranchesDropdown: true, diff --git a/web/main.ts b/web/main.ts index 645ec57a..b12ba1e5 100644 --- a/web/main.ts +++ b/web/main.ts @@ -1055,6 +1055,7 @@ class GitGraphView { } } ], [ + this.getViewIssueAction(refName, visibility.viewIssue, target), { title: 'Create Pull Request' + ELLIPSIS, visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null, @@ -1283,6 +1284,7 @@ class GitGraphView { } } ], [ + this.getViewIssueAction(refName, visibility.viewIssue, target), { title: 'Create Pull Request', visible: visibility.createPullRequest && this.gitRepos[this.currentRepo].pullRequestConfig !== null && branchName !== 'HEAD' && @@ -1507,6 +1509,36 @@ class GitGraphView { ]]; } + private getViewIssueAction(refName: string, visible: boolean, target: DialogTarget & RefTarget): ContextMenuAction { + const issueLinks: { url: string, displayText: string }[] = []; + + let issueLinking: IssueLinking | null, match: RegExpExecArray | null; + if (visible && (issueLinking = parseIssueLinkingConfig(this.gitRepos[this.currentRepo].issueLinkingConfig)) !== null) { + issueLinking.regexp.lastIndex = 0; + while (match = issueLinking.regexp.exec(refName)) { + if (match[0].length === 0) break; + issueLinks.push({ + url: generateIssueLinkFromMatch(match, issueLinking), + displayText: match[0] + }); + } + } + + return { + title: 'View Issue' + (issueLinks.length > 1 ? ELLIPSIS : ''), + visible: issueLinks.length > 0, + onClick: () => { + if (issueLinks.length > 1) { + dialog.showSelect('Select which issue you want to view for this branch:', '0', issueLinks.map((issueLink, i) => ({ name: issueLink.displayText, value: i.toString() })), 'View Issue', (value) => { + sendMessage({ command: 'openExternalUrl', url: issueLinks[parseInt(value)].url }); + }, target); + } else if (issueLinks.length === 1) { + sendMessage({ command: 'openExternalUrl', url: issueLinks[0].url }); + } + } + }; + } + /* Actions */ diff --git a/web/settingsWidget.ts b/web/settingsWidget.ts index 4841f535..25d8ccea 100644 --- a/web/settingsWidget.ts +++ b/web/settingsWidget.ts @@ -204,11 +204,11 @@ class SettingsWidget { const issueLinkingConfig = this.repo.issueLinkingConfig || globalState.issueLinkingConfig; if (issueLinkingConfig !== null) { const escapedIssue = escapeHtml(issueLinkingConfig.issue), escapedUrl = escapeHtml(issueLinkingConfig.url); - html += '
Issue Regex:' + escapedIssue + '
Issue URL:' + escapedUrl + '
'; - html += '
' + SVG_ICONS.pencil + 'Edit
' + SVG_ICONS.close + 'Remove
'; + html += '
Issue Regex:' + escapedIssue + '
Issue URL:' + escapedUrl + '
' + + '
' + SVG_ICONS.pencil + 'Edit
' + SVG_ICONS.close + 'Remove
'; } else { - html += 'Issue Linking converts issue numbers in commit messages into hyperlinks, that open the issue in your issue tracking system.'; - html += '
' + SVG_ICONS.plus + 'Add Issue Linking
'; + html += 'Issue Linking converts issue numbers in commit & tag messages into hyperlinks, that open the issue in your issue tracking system. If a branch\'s name contains an issue number, the issue can be viewed via the branch\'s context menu.' + + '
' + SVG_ICONS.plus + 'Add Issue Linking
'; } html += ''; @@ -233,7 +233,7 @@ class SettingsWidget { 'Destination Branch:' + destinationBranch + '' + '
' + SVG_ICONS.pencil + 'Edit
' + SVG_ICONS.close + 'Remove
'; } else { - html += 'Pull Request Creation automates the opening and pre-filling of a Pull Request form, directly from a branches context menu.' + + html += 'Pull Request Creation automates the opening and pre-filling of a Pull Request form, directly from a branch\'s context menu.' + '
' + SVG_ICONS.plus + 'Configure "Pull Request Creation" Integration
'; } html += ''; @@ -591,7 +591,7 @@ class SettingsWidget { dialog.showForm(html, [ { type: DialogInputType.Text, name: 'Issue Regex', default: defaultIssueRegex !== null ? defaultIssueRegex : '', placeholder: null, info: 'A regular expression that matches your issue numbers, with one or more capturing groups ( ) that will be substituted into the "Issue URL".' }, - { type: DialogInputType.Text, name: 'Issue URL', default: defaultIssueUrl !== null ? defaultIssueUrl : '', placeholder: null, info: 'The issue\'s URL in your project’s issue tracking system, with placeholders ($1, $2, etc.) for the groups captured ( ) in the "Issue Regex".' }, + { type: DialogInputType.Text, name: 'Issue URL', default: defaultIssueUrl !== null ? defaultIssueUrl : '', placeholder: null, info: 'The issue\'s URL in your issue tracking system, with placeholders ($1, $2, etc.) for the groups captured ( ) in the "Issue Regex".' }, { type: DialogInputType.Checkbox, name: 'Use Globally', value: defaultUseGlobally, info: 'Use the "Issue Regex" and "Issue URL" for all repositories by default (it can be overridden per repository). Note: "Use Globally" is only suitable if identical Issue Linking applies to the majority of your repositories (e.g. when using JIRA or Pivotal Tracker).' } ], 'Save', (values) => { let issueRegex = (values[0]).trim(), issueUrl = (values[1]).trim(), useGlobally = values[2]; diff --git a/web/textFormatter.ts b/web/textFormatter.ts index 6230693f..cd963875 100644 --- a/web/textFormatter.ts +++ b/web/textFormatter.ts @@ -110,10 +110,7 @@ class TextFormatter { urls: boolean }>; private readonly commits: ReadonlyArray; - private readonly issueLinking: Readonly<{ - regexp: RegExp, - url: string - }> | null = null; + private readonly issueLinking: IssueLinking | null = null; private static readonly BACKTICK_REGEXP: RegExp = /(\\*)(`+)/gu; private static readonly BACKSLASH_ESCAPE_REGEXP: RegExp = /\\[\u0021-\u002F\u003A-\u0040\u005B-\u0060\u007B-\u007E]/gu; @@ -141,15 +138,8 @@ class TextFormatter { ? repoIssueLinkingConfig : globalState.issueLinkingConfig; - if (this.config.issueLinking && issueLinkingConfig !== null) { - try { - this.issueLinking = { - regexp: new RegExp(issueLinkingConfig.issue, 'gu'), - url: issueLinkingConfig.url - }; - } catch (e) { - this.issueLinking = null; - } + if (this.config.issueLinking) { + this.issueLinking = parseIssueLinkingConfig(issueLinkingConfig); } } @@ -266,12 +256,7 @@ class TextFormatter { type: TF.NodeType.Url, start: match.index, end: this.issueLinking.regexp.lastIndex - 1, - url: match.length > 1 - ? this.issueLinking.url.replace(/\$([1-9][0-9]*)/g, (placeholder, index) => { - const i = parseInt(index); - return i < match!.length ? match![i] : placeholder; - }) - : this.issueLinking.url, + url: generateIssueLinkFromMatch(match, this.issueLinking), displayText: match[0], contains: [] }); @@ -567,6 +552,9 @@ class TextFormatter { } } + +/* URL Element Methods */ + /** * Is an element an external or internal URL. * @param elem The element to check. @@ -593,3 +581,45 @@ function isExternalUrlElem(elem: Element) { function isInternalUrlElem(elem: Element) { return elem.classList.contains(CLASS_INTERNAL_URL); } + + +/* Issue Linking Methods */ + +interface IssueLinking { + readonly regexp: RegExp; + readonly url: string; +} + +const ISSUE_LINKING_ARGUMENT_REGEXP = /\$([1-9][0-9]*)/g; + +/** + * Parses the Issue Linking Configuration of a repository, so it's ready to be used for detecting issues and generating links. + * @param issueLinkingConfig The Issue Linking Configuration. + * @returns The parsed Issue Linking, or `NULL` if it's not available. + */ +function parseIssueLinkingConfig(issueLinkingConfig: GG.IssueLinkingConfig | null): IssueLinking | null { + if (issueLinkingConfig !== null) { + try { + return { + regexp: new RegExp(issueLinkingConfig.issue, 'gu'), + url: issueLinkingConfig.url + }; + } catch (_) { } + } + return null; +} + +/** + * Generate the URL for an issue link, performing all variable substitutions from a match. + * @param match The match produced by `IssueLinking.regexp`. + * @param issueLinking The Issue Linking. + * @returns The URL for the issue link. + */ +function generateIssueLinkFromMatch(match: RegExpExecArray, issueLinking: IssueLinking) { + return match.length > 1 + ? issueLinking.url.replace(ISSUE_LINKING_ARGUMENT_REGEXP, (placeholder, index) => { + const i = parseInt(index); + return i < match.length ? match[i] : placeholder; + }) + : issueLinking.url; +}