Changeset reference utilities
- // ==UserScript==
- // @name TFS 2017 Changeset History Helper
- // @namespace http://jonas.ninja
- // @version 1.8.0
- // @description Changeset reference utilities
- // @author @_jnblog
- // @match https://*.visualstudio.com/**/_versionControl*
- // @grant GM_addStyle
- // @grant GM_setClipboard
- // ==/UserScript==
- /* jshint -W097 */
- /* global GM_addStyle */
- /* jshint asi: true, multistr: true */
- var $ = unsafeWindow.jQuery
- var mergedChangesetRegex = /\(merge [^\)]* to QA\)/gi
- var buttonTemplate = $('<button class="ijg-copyButton">')
- var containerTemplate = $('<div class="ijg-copyButtons"></div>')
- var urls = {
- changesetLinkedWorkItems: '/_apis/tfvc/changesets/{}/workItems',
- changesetInfo: '/_apis/tfvc/changesets/{}',
- apiVersion: '?api-version=1.0',
- }
- waitForKeyElements('.ms-DetailsRow', doEverything, false)
- //waitForKeyElements(".vc-page-title[title^=Changeset]", addChangesetIdCopyUtilities, true)
- //$(document).on('mouseenter', '.ms-DetailsRow', highlightHistoryR###lt)
- // .on('mouseleave', '.ms-DetailsRow', unhighlightHistoryR###lt)
- function doEverything(historyR###lt) {
- historyR###lt = $(historyR###lt)
- spanifyText(historyR###lt)
- addCopyUtilities(historyR###lt)
- }
- function spanifyText(historyR###lt) {
- // wraps changeset/task IDs with spans so they can be targeted individually
- // adds data to the newly-created spans
- historyR###lt.find('.ms-Link').each(function() {
- // commit messages may have either Tasks (deprecated in November 2016) or Changesets
- $(this).html($(this).text().replace(/[ct]\d{3,}/gi, function(match) {
- var id = match.replace(/[ct]/gi, '')
- if (match.startsWith('t')) {
- historyR###lt.data('ijgTaskId', id)
- return '<span class="ijg-task-id" data-ijg-task-id="' + id + '">' + match + '</span>'
- }
- return '<span class="ijg-changeset-id" data-ijg-changeset-id="' + id + '">' + match + '</span>'
- }))
- })
- historyR###lt.find('.change-info').each(function() {
- // '.ms-DetailsRow's will only have changesets, and they will not be prefixed with 'c'
- $(this).html($(this).text().replace(/\d{3,}/gi, function(match) {
- var changesetId = match.replace(/c/i, '')
- return '<span class="ijg-changeset-id" data-ijg-changeset-id="' + changesetId + '">' + match + '</span>'
- }))
- })
- }
- function addCopyUtilities(historyR###lt) {
- var $container = containerTemplate.clone()
- var changesetId = historyR###lt.find('.ms-Link')[0].getAttribute('href').match(/\d+$/)[0]
- var url = historyR###lt.find('a.ms-Link').prop('href')
- var formattedUrl = '*Changeset ' + changesetId + ": " + historyR###lt.find('a.ms-Link').text() + '*\n' + url
- var message = createCommitMessage(historyR###lt, changesetId)
- $container
- .append(buttonTemplate.clone().text(changesetId) .addClass('ijg-js-copyButton').data('ijgCopyText', changesetId))
- .append(buttonTemplate.clone().text('Link') .addClass('ijg-js-copyButton').data('ijgCopyText', formattedUrl))
- .append(buttonTemplate.clone().text('Merge Message').addClass('ijg-js-copyButton').data('ijgCopyText', message))
- historyR###lt.find('.card-details-section').before($container)
- // after the ajax call returns, append task IDs to the button
- addTaskUtilities(historyR###lt, function(taskIds) {
- var thisTaskButton
- if (taskIds.length <= 0) {
- // no task IDs to add. Might as well just stop here.
- tasksIds = ''
- thisTaskButton = buttonTemplate.clone().html(' ').addClass('ijg-js-copyButton ijg-js-copyTask').css('width', 56)
- $container.find('button').last().before(thisTaskButton)
- return
- }
- taskIds = taskIds.reduce(function(prev, cur) {
- return prev + ', ' + cur
- })
- thisTaskButton = buttonTemplate.clone().text('Task IDs').addClass('ijg-js-copyButton ijg-js-copyTask').data('ijgCopyText', taskIds)
- $container.find('button').last().before(thisTaskButton)
- // merge "Task IDs" buttons vertically to group commits on the same task
- // first, store the data
- var idsKey = 'ijg-taskIds'
- var countKey = 'ijg-countMergeRows'
- historyR###lt.data(idsKey, taskIds).data(countKey, 1)
- // second, merge down if the row below already has taskIDs, and they are the same
- var next = historyR###lt.next()
- if (next.size() > 0 && next.data(idsKey) == taskIds) {
- // the next button matches this one. Merge into this one, and remove the next button
- var nextButton = next.find('.ijg-js-copyTask')
- }
- })
- }
- function addTaskUtilities(historyR###lt, callback) {
- $.ajax({
- method: 'GET',
- dataType: 'json',
- url: window.location.origin + urls.changesetLinkedWorkItems.replace('{}', historyR###lt.find('a.ms-Link')[0].getAttribute('href').match(/\d+$/)[0]) + urls.apiVersion,
- success: function(data) {
- var idArray = []
- if (data !== undefined && data.count > 0) {
- idArray = data.value.map(function(el) {
- return el.id
- })
- }
- callback.call(historyR###lt, idArray)
- createTaskContainer(historyR###lt, idArray)
- }
- })
- }
- function createTaskContainer(historyR###lt, idArray) {
- // makes a positioned div in the right place to hold Task info
- var container = $('<div class="ijg-tasks-container">')
- historyR###lt.append(container)
- idArray.forEach(function(taskId) {
- var $task = $('<div class=ijg-task-link>').data('ijgTaskId', taskId)
- var $link = $('<a target="_blank">')
- .text(taskId)
- .prop('href', window.location.origin + '/' + window.location.pathname.split('/')[1] + '/_workitems?id=' + taskId)
- container.append($task.append($link))
- })
- if (container[0].scrollHeight > container[0].offsetHeight) { // broken
- container.addClass('is-overflow')
- }
- }
- function addChangesetIdCopyUtilities(pageTitle) {
- var $pageTitle = $(pageTitle)
- if ($pageTitle.hasClass('added')) {
- return
- }
- $pageTitle.addClass('added')
- var id = $pageTitle.text().replace('Changeset ', '')
- var $copyLinkInput = $('<input value="' + id + '">').addClass('ijg-copy-changeset-page-link')
- $pageTitle.after($copyLinkInput)
- }
- function highlightHistoryR###lt(e) {
- var changeset = $(this).data('changeList')
- var changesetId = changeset.changesetId
- var tasks
- var mainHistoryR###lt = $('.r###lt-details .change-info .ijg-changeset-id[data-ijg-changeset-id=' + changesetId + ']').closest('.ms-DetailsRow')
- var matchingChangesets = $('span.ijg-changeset-id[data-ijg-changeset-id="' + changesetId + '"]')
- if (matchingChangesets.size() > 1) {
- matchingChangesets.each(function() {
- var matchingChangesetId = $(this)
- matchingChangesetId.css('color', 'red').closest('.ms-DetailsRow').css('background-color', 'beige')
- })
- mainHistoryR###lt.css('background-color', '#D1D1A9')
- }
- }
- function unhighlightHistoryR###lt(e) {
- $('span.ijg-changeset-id').css('color', '').closest('.ms-DetailsRow').css('background-color', '')
- }
- function displayR###lt($cursorContainer) {
- var cursorClass = 'ijg-check'
- $cursorContainer.addClass(cursorClass)
- window.setTimeout(function() {
- $cursorContainer.removeClass(cursorClass)
- }, 1750)
- setGreenCheckCursor()
- }
- /**
- If `historyR###lt` is a jQuery object, expect it to contain changelist data.
- If it is a string, expect it to be a selector string that contains the full commit message.
- */
- function createCommitMessage(historyR###lt, changesetId) {
- var optMessage = historyR###lt.find('a.ms-Link').text().trim()
- if (optMessage.match(mergedChangesetRegex)) {
- // a changeset that's already merged to QA should merge to Release
- optMessage = optMessage.replace(mergedChangesetRegex, '(merge c' + changesetId + ' to Release)')
- } else {
- optMessage = '(merge c' + changesetId + ' to QA) ' + optMessage
- }
- return optMessage
- }
- ;(function addStyles () {
- var styles = '\
- .ms-DetailsRow {\
- position: relative;\
- }\
- .ms-DetailsRow .ms-DetailsRow-cell {\
- width: 100% !important;\
- }\
- .ms-DetailsRow-fields {\
- width: calc(100% - 58px);\
- }\
- .avatar-image-card {\
- display: flex;\
- }\
- span.ijg-changeset-id { \
- border-bottom: 1px dotted #ccc; \
- } \
- div > span.ijg-changeset-id { \
- cursor: default; \
- } \
- .ijg-copyButtons { \
- margin-left: 13px;\
- position: static;\
- } \
- .r###lt-details { \
- padding-left: 276px;\
- } \
- input.ijg-copy-changeset-page-link {\
- cursor: pointer;\
- text-align: center;\
- width: 80px;\
- margin: 0 16px;\
- border: 1px solid #ccc;\
- vertical-align: middle; \
- }\
- .change-link-container { \
- display: inline-block; \
- }\
- .ijg-tasks-container {\
- top: 0; \
- right: 0;\
- height: 100%;\
- overflow-y: auto;\
- padding: 4px 8px 4px;\
- position: absolute;\
- }\
- .ijg-tasks-container.is-overflow {\
- border-bottom: 2px dashed red;\
- }\
- .ijg-tasks-container.is-overflow:hover {\
- border: 1px solid;\
- overflow: visible;\
- background-color: white;\
- max-height: initial;\
- z-index: 1;\
- }\
- .ijg-check {\
- cursor: url(data:image/svg+xml;utf8;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/Pgo8IS0tIEdlbmVyYXRvcjogQWRvYmUgSWxsdXN0cmF0b3IgMTYuMC4wLCBTVkcgRXhwb3J0IFBsdWctSW4gLiBTVkcgVmVyc2lvbjogNi4wMCBCdWlsZCAwKSAgLS0+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0iQ2FwYV8xIiB4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjI0cHgiIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDQxNS41ODIgNDE1LjU4MiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgNDE1LjU4MiA0MTUuNTgyOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPHBhdGggZD0iTTQxMS40Nyw5Ni40MjZsLTQ2LjMxOS00Ni4zMmMtNS40ODItNS40ODItMTQuMzcxLTUuNDgyLTE5Ljg1MywwTDE1Mi4zNDgsMjQzLjA1OGwtODIuMDY2LTgyLjA2NCAgIGMtNS40OC01LjQ4Mi0xNC4zNy01LjQ4Mi0xOS44NTEsMGwtNDYuMzE5LDQ2LjMyYy01LjQ4Miw1LjQ4MS01LjQ4MiwxNC4zNywwLDE5Ljg1MmwxMzguMzExLDEzOC4zMSAgIGMyLjc0MSwyLjc0Miw2LjMzNCw0LjExMiw5LjkyNiw0LjExMmMzLjU5MywwLDcuMTg2LTEuMzcsOS45MjYtNC4xMTJMNDExLjQ3LDExNi4yNzdjMi42MzMtMi42MzIsNC4xMTEtNi4yMDMsNC4xMTEtOS45MjUgICBDNDE1LjU4MiwxMDIuNjI4LDQxNC4xMDMsOTkuMDU5LDQxMS40Nyw5Ni40MjZ6IiBmaWxsPSIjMmQ5ZTFlIi8+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPGc+CjwvZz4KPC9zdmc+Cg==), auto !important;\
- }\
- button.ijg-copyButton {\
- margin-left: 8px;\
- margin-top: 10px;\
- padding: 2px 6px;\
- font-size: 12px;\
- }\
- .ijg-copyButton--extended {\
- vertical-align: top;\
- position: absolute;\
- }\
- .ijg-copyButton--extended + .ijg-copyButton {\
- margin-left: 72px;\
- }\
- .comments-indicator-container {\
- display: table-cell !important;\
- width: 28px;\
- }'
- var animationStyles = '\
- button.ijg-copyButton {\
- transition: box-shadow 100ms, background-color 250ms 100ms linear, width 400ms, opacity 400ms, padding 400ms;\
- }\
- .fade {\
- opacity: 0 !important;\
- width: 0 !important;\
- padding: 2px 0 !important;\
- margin-left: 0 !important;\
- }\
- .offset .fade {\
- opacity: 1 !important;\
- width: 41px !important;\
- padding: 2px 6px !important;\
- margin-left: 8px !important;\
- }\
- .offset .r###lt-details {\
- transition: padding-left 400ms -35ms;\
- }'
- GM_addStyle(styles)
- //GM_addStyle(animationStyles)
- })()
- function waitForKeyElements(
- // CC BY-NC-SA 4.0. Author: BrockA
- selectorTxt,
- actionFunction,
- bWaitOnce
- ) {
- var targetNodes, btargetsFound;
- targetNodes = $(selectorTxt);
- if (targetNodes && targetNodes.length > 0) {
- btargetsFound = true;
- /*--- Found target node(s). Go through each and act if they
- are new.
- */
- targetNodes.each(function() {
- var jThis = $(this);
- var alreadyFound = jThis.data('alreadyFound') || false;
- if (!alreadyFound) {
- //--- Call the payload function.
- var cancelFound = actionFunction(jThis);
- if (cancelFound)
- btargetsFound = false;
- else
- jThis.data('alreadyFound', true);
- }
- });
- } else {
- btargetsFound = false;
- }
- //--- Get the timer-control variable for this selector.
- var controlObj = waitForKeyElements.controlObj || {};
- var controlKey = selectorTxt.replace(/[^\w]/g, "_");
- var timeControl = controlObj[controlKey];
- //--- Now set or clear the timer as appropriate.
- if (btargetsFound && bWaitOnce && timeControl) {
- //--- The only condition where we need to clear the timer.
- clearInterval(timeControl);
- delete controlObj[controlKey]
- } else {
- //--- Set a timer, if needed.
- if (!timeControl) {
- timeControl = setInterval(function() {
- waitForKeyElements(selectorTxt,
- actionFunction,
- bWaitOnce
- );
- },
- 300
- );
- controlObj[controlKey] = timeControl;
- }
- }
- waitForKeyElements.controlObj = controlObj;
- }
- function setGreenCheckCursor() {
- /// from https://bugs.chromium.org/p/chromium/issues/detail?id=26723#c87
- if (document.body.style.cursor != cursorUrl) {
- var wkch = document.createElement("div");
- wkch.style.overflow = "hidden";
- wkch.style.position = "absolute";
- wkch.style.left = "0px";
- wkch.style.top = "0px";
- wkch.style.width = "100%";
- wkch.style.height = "100%";
- var wkch2 = document.createElement("div");
- wkch2.style.width = "200%";
- wkch2.style.height = "200%";
- wkch.appendChild(wkch2);
- document.body.appendChild(wkch);
- document.body.style.cursor = cursorUrl;
- wkch.scrollLeft = 1;
- wkch.scrollLeft = 0;
- document.body.removeChild(wkch);
- }
- }