Skip to content

Instantly share code, notes, and snippets.

@MaienM
Last active May 17, 2018 11:42
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MaienM/e6e81d46a2acaf12ac73e365b53e8877 to your computer and use it in GitHub Desktop.
Save MaienM/e6e81d46a2acaf12ac73e365b53e8877 to your computer and use it in GitHub Desktop.
Better bitbucket
// ==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