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 += '';
+ html += 'Issue Regex: | ' + escapedIssue + ' |
Issue URL: | ' + escapedUrl + ' |
' +
+ '';
} else {
- html += 'Issue Linking converts issue numbers in commit messages into hyperlinks, that open the issue in your issue tracking system.';
- html += '';
+ 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.' +
+ '';
}
html += '';
@@ -233,7 +233,7 @@ class SettingsWidget {
'Destination Branch: | ' + destinationBranch + ' |
' +
'';
} 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.' +
'';
}
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;
+}