Last active
May 17, 2018 11:42
-
-
Save MaienM/e6e81d46a2acaf12ac73e365b53e8877 to your computer and use it in GitHub Desktop.
Better bitbucket
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Better Bitbucket | |
// @namespace https://gist.github.com/MaienM/e6e81d46a2acaf12ac73e365b53e8877 | |
// @updateUrl https://gist.githubusercontent.com/MaienM/e6e81d46a2acaf12ac73e365b53e8877/raw/ | |
// @version 0.5.1 | |
// @description Improve the interface of Bitbucket, with a focus on code review | |
// @author MaienM | |
// @match https://bitbucket.org/*/pull-requests/* | |
// @match https://bitbucket.org/*/commits/* | |
// @match https://bitbucket.org/* | |
// @grant none | |
// @require https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.18.2/babel.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.16.0/polyfill.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js | |
// ==/UserScript== | |
/* jshint ignore:start */ | |
var inline_src = (<><![CDATA[ | |
/* jshint ignore:end */ | |
/* jshint esnext: false */ | |
/* jshint esversion: 6 */ | |
/** | |
* Helper functions. | |
*/ | |
/** | |
* Add a style block to the page. | |
* | |
* @param {String} name - The name of the style block. Only really useful for debugging purposes. | |
* @param {String|Function} style - A string containing the CSS code of the style block, or a callback. If a callback is | |
* used, this callback will be invoked every time the data of the style block changes, with the new data as argument, | |
* and it should return the new CSS code to use. | |
* @returns {Function} A function similar to jQuery.data that can be used to set the data the style block should use for | |
* rendering. | |
*/ | |
function addStyle(name, style) { | |
const $style = $('<style type="text/css"><style>'); | |
$('head').append($style); | |
$style.attr('data-name', 'better-bitbucket-' + name); | |
if (typeof style === 'string') { | |
$style.html(style); | |
return () => undefined; | |
} else { | |
const rerender = () => { | |
$style.html(style($style.data())); | |
}; | |
rerender(); | |
return (...args) => { | |
$style.data(...args); | |
rerender(); | |
}; | |
} | |
} | |
/** | |
* Start of the actions. | |
*/ | |
function addGeneralStyles(options, contextOptions) { | |
// Add the styles | |
addStyle('general', ` | |
/* Allow mixing an aui-icon into another one */ | |
.aui-icon .aui-icon { | |
margin-top: 10px !important; | |
margin-left: 999em; | |
} | |
.aui-icon .aui-icon:before { | |
font-size: 10px; | |
} | |
`); | |
} | |
// A two-pane view for the diffs in commits/pull requests, with a list of files in a side panel, and a single active file in the main panel. | |
function addTwoPaneView(options, contextOptions) { | |
// Add the styles | |
addStyle('two-pane', ` | |
body.fullscreen { | |
overflow: hidden; | |
} | |
.fullscreen #fullscreen-sidebar { | |
display: block; | |
} | |
#fullscreen-sidebar { | |
display: none; | |
background: white; | |
position: fixed; | |
top: 0; | |
bottom: 0; | |
left: 0; | |
z-index: 190; | |
border-left: 1em solid white; | |
} | |
#fullscreen-sidebar h1 { | |
font-size: 1.14285714em; | |
font-style: inherit; | |
font-weight: 600; | |
line-height: 1.25; | |
letter-spacing: -.006em; | |
margin-top: 0.6em | |
} | |
#fullscreen-sidebar .buttons { | |
position: absolute; | |
top: -0.2em; | |
right: 0; | |
} | |
#fullscreen-sidebar .buttons button { | |
background: none; | |
} | |
#fullscreen-sidebar .buttons a { | |
color: #172b4d; | |
} | |
#fullscreen-sidebar .buttons .aui-icon { | |
margin-right: 4px; | |
} | |
#fullscreen-dropdown .action-resize-sidebar .aui-icon { | |
transform: scale(0.85) translate(-1px, 0) rotate(45deg); | |
} | |
#fullscreen-sidebar .tree.treeview { | |
position: absolute; | |
top: 2.5em; | |
bottom: 0; | |
left: 0; | |
right: 0; | |
overflow: auto; | |
padding-top: 0.6em; | |
padding-left: 0; | |
} | |
#fullscreen-sidebar .tree { | |
padding-left: 14px; | |
margin-top: 0; | |
} | |
#fullscreen-sidebar .tree li { | |
display: flex; | |
flex-direction: column; | |
padding-left: 0; | |
} | |
#fullscreen-sidebar .tree li > span { | |
display: flex; | |
flex-wrap: nowrap; | |
} | |
#fullscreen-sidebar .tree li > span:hover { | |
background: #ffd; | |
} | |
#fullscreen-sidebar .tree .type-icon { | |
order: 0; | |
margin-top: 2px; | |
flex-shrink: 0; | |
} | |
#fullscreen-sidebar .tree .file:not(.mixed) .type-icon-mixed { | |
display: none; | |
} | |
#fullscreen-sidebar .tree .folder-li:not(.collapsed) > .folder .closed { | |
display: none; | |
} | |
#fullscreen-sidebar .tree .folder-li.collapsed > .folder .opened { | |
display: none; | |
} | |
#fullscreen-sidebar .tree .folder-li.collapsed .tree { | |
display: none; | |
} | |
#fullscreen-sidebar .tree .file .diff-summary-lozenge { | |
order: 0; | |
flex-shrink: 0; | |
color: #333; | |
height: 10px; | |
} | |
#fullscreen-sidebar .tree .file .diff-summary-lozenge.aui-lozenge-success { | |
background-color: #60b070; | |
} | |
#fullscreen-sidebar .tree .file .diff-summary-lozenge.aui-lozenge-complete { | |
background-color: #a5b3c2; | |
} | |
#fullscreen-sidebar .tree .file .diff-summary-lozenge.aui-lozenge-error { | |
background-color: #e8a29b; | |
} | |
#fullscreen-sidebar .tree .file .count-badge { | |
order: 2; | |
flex-shrink: 0; | |
background-color: inherit; | |
margin-top: 2px; | |
} | |
#fullscreen-sidebar .tree .filename { | |
order: 1; | |
margin: -1px 0 1px 4px; | |
} | |
#fullscreen-sidebar .tree .filename .sep { | |
color: teal; | |
} | |
#fullscreen-sidebar .toggle { | |
position: absolute; | |
width: 1em; | |
top: 0; | |
bottom: 0; | |
right: -1em; | |
display: flex; | |
flex-direction: column; | |
align-item: center; | |
justify-content: center; | |
} | |
#fullscreen-sidebar .toggle:hover { | |
background-color: #eee; | |
} | |
.fullscreen.sidebar-collapsed #fullscreen-sidebar .toggle-hide { | |
display: none; | |
} | |
body.fullscreen:not(.sidebar-collapsed) #fullscreen-sidebar .toggle-show { | |
display: none; | |
} | |
.fullscreen #changeset-diff { | |
position: fixed; | |
top: 0; | |
bottom: 0; | |
right: 0; | |
padding: 1em; | |
overflow: auto; | |
background: white; | |
z-index: 190; | |
margin-top: 0 !important; | |
margin-left: 1em; | |
} | |
.fullscreen.sidebar-collapsed #fullscreen-sidebar { | |
width: 0; | |
margin-left: -1em; | |
} | |
.fullscreen.sidebar-collapsed #fullscreen-sidebar h1 { | |
display: none; | |
} | |
.fullscreen.sidebar-collapsed #changeset-diff { | |
left: -1em; | |
} | |
.fullscreen #changeset-diff .bb-udiff { | |
display: none; | |
margin-top: 0; | |
} | |
body:not(.fullscreen) #changeset-diff { | |
display: none; | |
} | |
`); | |
const changeStyleSelected = addStyle('selected', ({ identifier }) => ` | |
.fullscreen .commit-files-summary .file-li[data-identifier="` + identifier + `"] { | |
background: #fef; | |
} | |
.fullscreen #changeset-diff .bb-udiff[data-identifier="` + identifier + `"] { | |
display: block; | |
} | |
`); | |
const changeStyleSidebar = addStyle('sidebar', ({ width }) => ` | |
#fullscreen-sidebar { | |
width: ` + width + `; | |
} | |
.fullscreen #changeset-diff { | |
left: ` + width + `; | |
} | |
`); | |
// Trigger the load of a file | |
const loadFile = (identifier) => { | |
// Trigger load if needed | |
const loadLink = $('#changeset-diff .bb-udiff[data-identifier="' + identifier + '"] a.load-diff')[0]; | |
if (loadLink) { | |
loadLink.click(); | |
} | |
}; | |
// Set a file as selected, and load if needed | |
let selectedIdentifier = null; | |
const setSelected = (identifier) => { | |
// Keep track of the currently selected item | |
selectedIdentifier = identifier; | |
contextOptions.set('active-file', identifier); | |
// Trigger load if needed | |
loadFile(identifier); | |
// Re-write the style block to show the proper selection & diff | |
changeStyleSelected('identifier', identifier); | |
// Make sure the file is visible in the sidebar | |
$('#fullscreen-sidebar .treeview .file-li[data-identifier="' + contextOptions.get('active-file') + '"]').parents('.folder-li').removeClass('collapsed'); | |
// Scroll the right panel to the top | |
$('#changeset-diff').scrollTop(0); | |
}; | |
// Add markers to a filename to allow wrapping when needed | |
const wrapFilename = (filename) => filename | |
.replace(/\//g, '<span class="sep">/</span><wbr />') // Allow breaking after a path separator, and highlight separators | |
.replace(/([A-Z])([a-z])/g, '<wbr />$1$2') // Allow breaking after a word | |
.replace(/([a-z])([A-Z])/g, '$1<wbr />$2'); // Allow breaking before a new word | |
// Build the sidebar | |
const buildSidebar = () => { | |
// The root element | |
$('#fullscreen-sidebar').remove(); | |
const $sidebar = $('<div id="fullscreen-sidebar"></div>'); | |
$('body').append($sidebar); | |
// Add a header | |
const $header = $('#pullrequest-diff > section.main h1, #commit-summary h1').clone(); | |
$sidebar.append($header); | |
// The buttons after the header | |
const $buttons = $(` | |
<div id="fullscreen-buttons" class="buttons aui-toolbar2" role="toolbar"> | |
<div class="aui-toolbar2-inner"> | |
<button class="aui-button aui-button-compact aui-dropdown2-trigger aui-dropdown2-trigger-arrowless action-more" aria-controls="fullscreen-dropdown"> | |
<span class="aui-icon aui-icon-small aui-iconfont-more"></span> | |
More | |
</button> | |
<button class="aui-button aui-button-compact action-close"> | |
<span class="aui-icon aui-icon-small aui-iconfont-close-dialog"></span>Close | |
</button> | |
</div> | |
</div> | |
`); | |
$sidebar.append($buttons); | |
// The close button | |
$buttons.find('.action-close').on('click', () => { | |
// Exit out of full screen mode | |
$('body').removeClass('fullscreen'); | |
}); | |
// The treeview | |
const $treeview = $('<ul class="treeview tree commit-files-summary"></ul>'); | |
$sidebar.append($treeview); | |
// The dropdown menu | |
const $dropdown = $(` | |
<aui-dropdown-menu id="fullscreen-dropdown"> | |
<aui-section label="actions"> | |
<aui-item-link href="#" class="action-collapse-all"> | |
<span class="aui-icon aui-icon-small aui-iconfont-devtools-folder-closed"></span> | |
Collapse all | |
</aui-item-link> | |
<aui-item-link href="#" class="action-open-all"> | |
<span class="aui-icon aui-icon-small aui-iconfont-devtools-folder-open"></span> | |
Open all | |
</aui-item-link> | |
<aui-item-link href="#" class="action-resize-sidebar"> | |
<span class="aui-icon aui-icon-small aui-iconfont-focus"></span> | |
Resize sidebar | |
</aui-item-link> | |
</aui-section> | |
<aui-section label="options"> | |
<aui-item-checkbox interactive class="option-start-collapsed"> | |
Start collapsed | |
</aui-item-checkbox> | |
<aui-item-checkbox interactive class="option-remember-active-file"> | |
Remember active file | |
</aui-item-checkbox> | |
</aui-section> | |
</aui-dropdown-menu> | |
`); | |
$sidebar.append($dropdown); | |
$dropdown.find('.action-collapse-all').on('click', () => { | |
$treeview.find('.folder-li').addClass('collapsed'); | |
}); | |
$dropdown.find('.action-open-all').on('click', () => { | |
$treeview.find('.folder-li').removeClass('collapsed'); | |
}); | |
$dropdown.find('.action-resize-sidebar').on('click', () => { | |
// Prompt for new width | |
const answer = prompt('Sidebar width, optionally with unit (20%, 300px, etc)', options.get('pullrequest-sidebar-width')); | |
if (!answer) { | |
return; | |
} | |
// Validate/resolve units | |
const $span = $('<span />'); | |
$span.css('width', answer); | |
if (!$span.width()) { | |
alert('This is not a valid width!'); | |
return; | |
} | |
const width = $span.css('width'); | |
// Store + apply | |
options.set('pullrequest-sidebar-width', width); | |
changeStyleSidebar('width', width); | |
}); | |
const bindBoolean = (selector, key) => { | |
$dropdown.find(selector).on('change', (e) => { | |
options.set(key, e.target.hasAttribute('checked')); | |
}).attr('checked', options.get(key)); | |
}; | |
bindBoolean('.option-start-collapsed', 'pullrequest-start-collapsed'); | |
bindBoolean('.option-remember-active-file', 'pullrequest-remember-active-file'); | |
// Get all files | |
const files = _.chain($('.file')) | |
.map($) | |
.map(($e) => [$e.data('file-identifier'), $e]) | |
.fromPairs() | |
.value(); | |
const fileNames = _.keys(files); | |
// Determine which folders should be present | |
_.mixin({ cleanArray: _.flow([_.filter, _.sortBy, _.sortedUniq]) }); | |
const fileFolders = _.chain(files) | |
.keys() | |
.map((p) => p.split('/').slice(0, -1).join('/')) | |
.cleanArray() | |
.value(); | |
const commonRootFolders = _.chain(fileFolders) | |
.map((path) => { | |
const dirs = path.split('/'); | |
return _(dirs.length) | |
.range() | |
.reverse() | |
.map((l) => dirs.slice(0, l + 1).join('/')) | |
.find((p) => fileFolders.filter((f) => f.indexOf(p) === 0).length > 1); | |
}) | |
.cleanArray() | |
.value(); | |
const folders = _.chain([fileFolders, commonRootFolders]) | |
.flatten() | |
.cleanArray() | |
.filter((path) => fileNames.filter((f) => f.indexOf(path) === 0).length > 1) | |
.value(); | |
// Add the folders to the treeview | |
const folderElems = { '': $treeview }; | |
const getParentPath = (path) => { | |
const dirs = path.split('/'); | |
return _.chain(dirs.length - 1) | |
.range() | |
.reverse() | |
.map((l) => dirs.slice(0, l + 1).join('/')) | |
.find((p) => folderElems[p] && p) | |
.value() || ''; | |
}; | |
_.each(folders, (path) => { | |
// Get the parent folder | |
const parentPath = getParentPath(path); | |
const $parent = folderElems[parentPath]; | |
// Add the current folder | |
const remaining = parentPath ? path.replace(parentPath + '/', '') : path; | |
const $current = $(` | |
<li class="folder-li" title="` + path + `"> | |
<span class="folder"> | |
<span class="aui-icon aui-icon-small aui-iconfont-devtools-folder-closed type-icon closed"></span> | |
<span class="aui-icon aui-icon-small aui-iconfont-devtools-folder-open type-icon opened"></span> | |
<span class="filename">` + wrapFilename(remaining) + `</span> | |
</span> | |
<ul class="tree subtree"></ul> | |
</li> | |
`); | |
$parent.append($current); | |
// Store the subtree for subfolders to use | |
const $subtree = $current.find('.subtree'); | |
folderElems[path] = $subtree; | |
}); | |
// Add the files to the correct folders | |
_(files).toPairs().each(([path, $elem]) => { | |
const parentPath = getParentPath(path); | |
const localPath = parentPath ? path.replace(parentPath + '/', '') : path; | |
const $folder = folderElems[parentPath]; | |
const $file = $(` | |
<li | |
class="file-li ` + (localPath.indexOf('/') < 0 ? '' : 'mixed-li') + `" | |
title="` + $elem.data('file-identifier') + `" | |
data-identifier="` + $elem.data('file-identifier') + `" | |
> | |
<span class="file ` + (localPath.indexOf('/') < 0 ? '' : 'mixed') + `"> | |
<span class="aui-icon aui-icon-small aui-iconfont-devtools-file type-icon"> | |
<span class="aui-icon aui-icon-small aui-iconfont-devtools-folder-open type-icon type-icon-mixed"></span> | |
</span> | |
` + ($elem.find('.diff-summary-lozenge').prop('outerHTML') || '') + ` | |
<span class="filename">` + wrapFilename(localPath) + `</span> | |
` + ($elem.find('.count-badge').prop('outerHTML') || '') + ` | |
</span> | |
</li> | |
`); | |
$folder.append($file); | |
$file.find('a').attr('href', '#'); | |
}); | |
// Move the folders to the bottom of the subtrees | |
$sidebar.find('.tree').each(function() { | |
const $this = $(this); | |
$this.find('> .mixed-li').appendTo($this); | |
$this.find('> .folder-li').appendTo($this); | |
}); | |
// When clicking one of the folders, toggle the subtree | |
$sidebar.find('.folder').on('click', function() { | |
$(this).parent('li').toggleClass('collapsed'); | |
}); | |
// When clicking one of the files, show & load the appropriate file | |
$sidebar.find('.file').on('click', function() { | |
setSelected($(this).parent('li').data('identifier')); | |
}); | |
// The sidebar toggle | |
const $toggle = $(` | |
<div class="toggle"> | |
<span class="aui-icon aui-icon-small aui-iconfont-arrows-left toggle-hide">Hide sidebar</span> | |
<span class="aui-icon aui-icon-small aui-iconfont-arrows-right toggle-show">Show sidebar</span> | |
</div> | |
`); | |
$sidebar.append($toggle); | |
$toggle.on('click', () => { | |
$('body').toggleClass('sidebar-collapsed'); | |
}); | |
// Apply the options | |
if (options.get('pullrequest-start-collapsed')) { | |
$dropdown.find('.action-collapse-all').click(); | |
} | |
if (options.get('pullrequest-remember-active-file') && contextOptions.get('active-file')) { | |
setSelected(contextOptions.get('active-file')); | |
} | |
changeStyleSidebar('width', options.get('pullrequest-sidebar-width', '20%')); | |
}; | |
// Add link to enter full screen mode | |
const $enter = $(` | |
<li id="fullscreen-pullrequest" class="detail-summary--item"> | |
<span class="aui-icon aui-icon-small aui-iconfont-layout-2col-right-large detail-summary--icon"></span> | |
<a id="fullscreen-open" href="#"> | |
View in two-column layout | |
</a> | |
</li> | |
`); | |
$('.detail-summary--section').last().append($enter); | |
$enter.on('click', (e) => { | |
// Go to full screen mode | |
$('body').addClass('fullscreen'); | |
// (Re)build the sidebar | |
buildSidebar(); | |
// If no file is currently selected, select the first file | |
if (!selectedIdentifier) { | |
setSelected($('#changeset-diff .bb-udiff').first().data('identifier')); | |
} | |
// Don't set the anchor in the URL | |
e.preventDefault(); | |
e.stopPropagation(); | |
}); | |
} | |
// Permalinks for markdown headers. | |
function addMarkdownHeaders(options, contextOptions) { | |
// Add the styles | |
{$('head').append(` | |
<style type="text/css"> | |
.wiki-content .permalink .aui-icon { | |
vertical-align: middle; | |
margin-left: 0.5em; | |
} | |
.wiki-content .permalink .aui-icon:before { | |
font-size: 14px; | |
} | |
</style> | |
`);} | |
// Add permalinks to the wiki pages | |
$('.wiki-content').find('h1, h2, h3, h4, h5, h6').each(function() { | |
console.log(this); | |
const $this = $(this); | |
const $link = $('<a href="#' + $this.attr('id') + '" class="permalink"><span class="aui-icon aui-icon-small aui-iconfont-link">Permalink</span></a>'); | |
$this.append($link); | |
}); | |
} | |
/** | |
* Start of the main entrypoint. | |
*/ | |
function main() { | |
// Get bitbucket data from the body | |
const bitbucketData = $('body').data(); | |
// Determine what context/actions to use for the current page | |
const context = ['betterBitbucket', bitbucketData.currentRepo.id]; | |
const actions = []; | |
actions.push(addGeneralStyles); | |
if (bitbucketData.currentPr) { | |
context.push('pull-request'); | |
context.push(bitbucketData.currentPr.localId); | |
actions.push(addTwoPaneView); | |
} else if (bitbucketData.currentCset) { | |
context.push('commit'); | |
context.push(bitbucketData.currentCset); | |
actions.push(addTwoPaneView); | |
} else { | |
context.push('misc'); | |
actions.push(addMarkdownHeaders); | |
} | |
// Manage options + data for the current object | |
const wrapLocalStorage = (storageKey) => { | |
const data = JSON.parse(localStorage.getItem(storageKey)) || {}; | |
const get = _.partial(_.get, data); | |
const set = (key, value) => { | |
_.set(data, key, value); | |
localStorage.setItem(storageKey, JSON.stringify(data)); | |
}; | |
return { get, set }; | |
}; | |
const options = wrapLocalStorage('betterBitbucket'); | |
const contextOptions = wrapLocalStorage(context.join('::')); | |
// Apply the actions | |
actions.forEach((action) => { | |
console.log('Better Bitbucket: Applying ' + action.name); | |
action(options, contextOptions); | |
}); | |
} | |
main(); | |
/* jshint ignore:start */ | |
]]></>).toString(); | |
var c = Babel.transform(inline_src, { presets: [ "es2015", "es2016" ] }); | |
eval(c.code); | |
/* jshint ignore:end */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment