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); } }