gerrit-f-button
Version:
Gerrit F button TamperMonkey script
1,061 lines (878 loc) • 33.5 kB
JavaScript
// ==UserScript==
// @name Gerrit F Button
// @namespace ahmad@amireh.net
// @include https://gerrit.instructure.com/*
// @version 2
// @grant none
// @grant GM_setClipboard
// @run-at document.end
// ==/UserScript==
/* gerrit-f-button.js v2.1.2 */
(function () {
'use strict';
// Utils
function TreeView(fileList) {
var tree = {
items: [],
children: {}
};
function getBranch(path) {
var fragments = path.split('/').filter(function(x) { return x.length > 0; });
var branch = tree;
fragments.forEach(function(fragment) {
if (!branch.children[fragment]) {
branch.children[fragment] = { items: [], children: {} };
}
branch = branch.children[fragment];
});
return branch;
}
fileList.forEach(function(file) {
getBranch(file.filePath.split('/').slice(0, -1).join('/')).items.push(file);
});
return tree;
}
function classSet(classNames) {
return Object.keys(classNames).reduce(function(className, key) {
return !!classNames[key] ? (className + ' ' + key) : className;
}, '');
}
function injectCSS(string) {
var styleNode = document.createElement('style');
styleNode.innerHTML = string;
document.head.appendChild(styleNode);
}
function discardLeadingSlash(s) {
return s.replace(/^\//, '');
}
function copyToClipboard(string) {
// GreaseMonkey sandbox:
if (typeof GM_setClipboard !== 'undefined') {
GM_setClipboard(string);
}
}
var Styles = function() {/*
@font-face {
font-family: 'gerrit-f-button';
src:
url(data:application/font-woff;charset=utf-8;base64,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) format('woff'),
url(data:application/x-font-ttf;charset=utf-8;base64,AAEAAAALAIAAAwAwT1MvMg8SBpIAAAC8AAAAYGNtYXDqJOpPAAABHAAAAFxnYXNwAAAAEAAAAXgAAAAIZ2x5ZppGirMAAAGAAAABoGhlYWQKmABpAAADIAAAADZoaGVhB8EDyAAAA1gAAAAkaG10eBIAAEIAAAN8AAAAHGxvY2EAyAFUAAADmAAAABBtYXhwAAsAOgAAA6gAAAAgbmFtZYv5+kUAAAPIAAAB5nBvc3QAAwAAAAAFsAAAACAAAwOAAZAABQAAApkCzAAAAI8CmQLMAAAB6wAzAQkAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAABAAADp6APA/8AAQAPAAEAAAAABAAAAAAAAAAAAAAAgAAAAAAADAAAAAwAAABwAAQADAAAAHAADAAEAAAAcAAQAQAAAAAwACAACAAQAAQAg6Z3p6P/9//8AAAAAACDpnenn//3//wAB/+MWZxYeAAMAAQAAAAAAAAAAAAAAAAABAAH//wAPAAEAAAAAAAAAAAACAAA3OQEAAAAAAQAAAAAAAAAAAAIAADc5AQAAAAABAAAAAAAAAAAAAgAANzkBAAAAAAMAQP/AA8ADwAAZACEANwAAAS4BJy4BJy4BIyEiBhURFBYzITI2NRE0JicnHgEXIzUeARMUBiMhIiY1ETQ2MzA6AjEVFBY7AQOWES0ZGjMXJykL/hAhLy8hAuAhLw4chRclDZoRKYYJB/0gBwkJB5u6mxMN4ALbFzMaGS0RHA4vIfygIS8vIQJwCyknNhcpEZoNJfzoBwkJBwNgBwngDRMAAAAAAgABAEAD/wOAAA8AGwAAASEiBhcTHgEzITI2NxM2Jic0JiMhJyMiBh0BIQPY/FAUFwR2AyIUAqAUIgN2BBdsHBT+UCDQFBwDAALAHBP93hMcHBMCIhMcUBQcQBwUUAAAAgABAEAD/wOAAA8AGwAAATIWBwMOASMhIiYnAyY2MyUVITU0NjsBFyEyFgPYFBcEdgMiFP1gFCIDdgQXEwNZ/QAcFNAgAbAUHAJAHBP+XhMcHBMBohMc0JDQFBxAHAAAAQAAAAEAALTpHUtfDzz1AAsEAAAAAADTvN32AAAAANO83fYAAP/AA/8DwAAAAAgAAgAAAAAAAAABAAADwP/AAAAEAAAAAAAD/wABAAAAAAAAAAAAAAAAAAAABwQAAAAAAAAAAAAAAAIAAAAEAABABAAAAQQAAAEAAAAAAAoAFAAeAHAAoADQAAEAAAAHADgAAwAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAOAK4AAQAAAAAAAQAPAAAAAQAAAAAAAgAHAKgAAQAAAAAAAwAPAE4AAQAAAAAABAAPAL0AAQAAAAAABQALAC0AAQAAAAAABgAPAHsAAQAAAAAACgAaAOoAAwABBAkAAQAeAA8AAwABBAkAAgAOAK8AAwABBAkAAwAeAF0AAwABBAkABAAeAMwAAwABBAkABQAWADgAAwABBAkABgAeAIoAAwABBAkACgA0AQRnZXJyaXQtZi1idXR0b24AZwBlAHIAcgBpAHQALQBmAC0AYgB1AHQAdABvAG5WZXJzaW9uIDEuMABWAGUAcgBzAGkAbwBuACAAMQAuADBnZXJyaXQtZi1idXR0b24AZwBlAHIAcgBpAHQALQBmAC0AYgB1AHQAdABvAG5nZXJyaXQtZi1idXR0b24AZwBlAHIAcgBpAHQALQBmAC0AYgB1AHQAdABvAG5SZWd1bGFyAFIAZQBnAHUAbABhAHJnZXJyaXQtZi1idXR0b24AZwBlAHIAcgBpAHQALQBmAC0AYgB1AHQAdABvAG5Gb250IGdlbmVyYXRlZCBieSBJY29Nb29uLgBGAG8AbgB0ACAAZwBlAG4AZQByAGEAdABlAGQAIABiAHkAIABJAGMAbwBNAG8AbwBuAC4AAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) format('truetype'),
url(data:image/svg+xml;charset=utf-8;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/Pgo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiID4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8bWV0YWRhdGE+R2VuZXJhdGVkIGJ5IEljb01vb248L21ldGFkYXRhPgo8ZGVmcz4KPGZvbnQgaWQ9ImdlcnJpdC1mLWJ1dHRvbiIgaG9yaXotYWR2LXg9IjEwMjQiPgo8Zm9udC1mYWNlIHVuaXRzLXBlci1lbT0iMTAyNCIgYXNjZW50PSI5NjAiIGRlc2NlbnQ9Ii02NCIgLz4KPG1pc3NpbmctZ2x5cGggaG9yaXotYWR2LXg9IjEwMjQiIC8+CjxnbHlwaCB1bmljb2RlPSImI3gyMDsiIGhvcml6LWFkdi14PSI1MTIiIGQ9IiIgLz4KPGdseXBoIHVuaWNvZGU9IiYjeGU5OWQ7IiBnbHlwaC1uYW1lPSJmaWxlIiBkPSJNOTE3LjgwNiA3MzAuOTI0Yy0yMi4yMTIgMzAuMjkyLTUzLjE3NCA2NS43LTg3LjE3OCA5OS43MDRzLTY5LjQxMiA2NC45NjQtOTkuNzA0IDg3LjE3OGMtNTEuNTc0IDM3LjgyLTc2LjU5MiA0Mi4xOTQtOTAuOTI0IDQyLjE5NGgtNDk2Yy00NC4xMTIgMC04MC0zNS44ODgtODAtODB2LTg2NGMwLTQ0LjExMiAzNS44ODgtODAgODAtODBoNzM2YzQ0LjExMiAwIDgwIDM1Ljg4OCA4MCA4MHY2MjRjMCAxNC4zMzItNC4zNzIgMzkuMzUtNDIuMTk0IDkwLjkyNHpNNzg1LjM3NCA3ODUuMzc0YzMwLjctMzAuNyA1NC44LTU4LjM5OCA3Mi41OC04MS4zNzRoLTE1My45NTR2MTUzLjk0NmMyMi45ODQtMTcuNzggNTAuNjc4LTQxLjg3OCA4MS4zNzQtNzIuNTcyek04OTYgMTZjMC04LjY3Mi03LjMyOC0xNi0xNi0xNmgtNzM2Yy04LjY3MiAwLTE2IDcuMzI4LTE2IDE2djg2NGMwIDguNjcyIDcuMzI4IDE2IDE2IDE2IDAgMCA0OTUuOTU2IDAuMDAyIDQ5NiAwdi0yMjRjMC0xNy42NzIgMTQuMzI2LTMyIDMyLTMyaDIyNHYtNjI0eiIgLz4KPGdseXBoIHVuaWNvZGU9IiYjeGU5ZTc7IiBnbHlwaC1uYW1lPSJmb2xkZXIiIGQ9Ik05ODQuNSA3MDRoLTk0NWMtMjYuNCAwLTQzLjc2NC0yMS4xOC0zOC41ODYtNDcuMDY4bDExNy42NzItNTQ1Ljg2NGM1LjE3OC0yNS44ODggMzEuMDE0LTQ3LjA2OCA1Ny40MTQtNDcuMDY4aDY3MmMyNi4zOTggMCA1Mi4yMzQgMjEuMTggNTcuNDEyIDQ3LjA2OGwxMTcuNjc0IDU0NS44NjRjNS4xNzggMjUuODg4LTEyLjE4OCA0Ny4wNjgtMzguNTg2IDQ3LjA2OHpNODk2IDc4NGMwIDI2LjUxLTIxLjQ5IDQ4LTQ4IDQ4aC00MzJsLTMyIDY0aC0yMDhjLTI2LjUxIDAtNDgtMjEuNDktNDgtNDh2LTgwaDc2OHYxNnoiIC8+CjxnbHlwaCB1bmljb2RlPSImI3hlOWU4OyIgZ2x5cGgtbmFtZT0iZm9sZGVyLS1vcGVuIiBkPSJNOTg0LjUgNTc2YzI2LjQgMCA0My43NjQtMjEuMTggMzguNTg2LTQ3LjA2OGwtMTE3LjY3Mi00MTcuODY0Yy01LjE3OC0yNS44ODgtMzEuMDE0LTQ3LjA2OC01Ny40MTQtNDcuMDY4aC02NzJjLTI2LjQgMC01Mi4yMzYgMjEuMTgtNTcuNDE0IDQ3LjA2OGwtMTE3LjY3MiA0MTcuODY0Yy01LjE3OCAyNS44ODggMTIuMTg2IDQ3LjA2OCAzOC41ODYgNDcuMDY4aDk0NXpNODk2IDc4NHYtMTQ0aC03Njh2MjA4YzAgMjYuNTEgMjEuNDkgNDggNDggNDhoMjA4bDMyLTY0aDQzMmMyNi41MSAwIDQ4LTIxLjQ5IDQ4LTQ4eiIgLz4KPC9mb250PjwvZGVmcz48L3N2Zz4=) format('svg')
;
font-weight: normal;
font-style: normal;
}
[class^="f-button-icon__"],
[class*=" f-button-icon__"] {
font-family: 'gerrit-f-button' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.f-button-icon__file:before {
content: "\e99d";
}
.f-button-icon__folder:before {
content: "\e9e7";
}
.f-button-icon__folder--open:before {
content: "\e9e8";
}
body.gerrit--with-f-button {
margin-left: 280px;
}
body.gerrit--with-f-button-overlay {
margin-left: 0;
}
.f-button__frame {
position: fixed;
top: 0;
right: auto;
bottom: 0;
left: 0;
width: 240px;
overflow: auto;
border-color: #aaa;
border-right-style: solid;
border-right-width: 1px;
background: white;
padding: 10px;
font-size: 0.85em;
line-height: 1.4;
z-index: 6;
}
body.gerrit--with-f-button-overlay .f-button__frame {
width: 50%;
left: 25%;
top: 50%;
border-left-width: 1px;
border-left-style: solid;
border-top-style: solid;
border-top-width: 1px;
opacity: 0.85;
}
body.gerrit--with-f-button-overlay .f-button__frame:hover {
opacity: 1;
}
.f-button__frame--commented-only .f-button-file:not(.f-button-file--commented) {
display: none;
}
.f-button__frame--list-view .f-button-file__folder {
padding-left: 0;
}
.f-button__frame--list-view .f-button-file__folder-header {
display: none;
}
.f-button__controls {
border-top: 1px solid #ddd;
margin-top: 1em;
padding-top: 1em;
}
.f-button__controls label {
display: block;
}
.f-button__controls input {
margin-top: 0;
margin-bottom: 0;
vertical-align: middle;
}
.f-button-file {
list-style: none;
}
.f-button-file:hover, .f-button-file--selected {
background: #fcfa96;
}
.f-button-file--active {
background-color: #DEF;
}
.f-button-file__icon {
display: inline-block;
width: 16px;
height: 16px;
vertical-align: middle;
position: absolute;
text-align: left;
}
.f-button-file__link {
display: block;
margin-right: 2em;
margin-left: 16px;
padding-left: 0.25em;
line-height: 1.6;
text-decoration: none;
word-break: break-all;
}
.f-button-file__folder {
list-style: none;
margin-left: 0;
padding-left: 0.5em;
}
.f-button-file__folder-header {
font-weight: bold;
margin-left: 1.5em;
}
.f-button-file__folder-header .f-button-icon__folder {
margin-left: -1.5em;
}
.f-button-file__folder--root {
padding-left: 0;
margin: 0;
}
.f-button-file__comment-count {
position: absolute;
right: 0;
max-width: 2em;
padding-right: 1em;
text-align: right;
font-weight: bold;
line-height: 1.6;
}
.f-button-file__icon.f-button-icon__file { line-height: inherit; margin-top: 1px; }
.f-button-file__icon.f-button-icon__folder { margin-top: 2px; }
*/}.toString().replace('function () {/*', '').replace('*/}', '');
// TODO: we don't really need all of this, just expose two functions that
// calculate the target cursor within a list given a target
function LinkedList($, set) {
this.$ = $;
this.$set = $(set);
this.setCursor(0);
return this;
};
LinkedList.prototype.setCursor = function(cursor) {
if (cursor instanceof this.$) {
cursor = this.$set.index( cursor );
}
this.cursor = cursor;
this.$cursor = this.$(this.$set.get(this.cursor));
return this;
};
LinkedList.prototype.getNext = function(originalCursor) {
if (originalCursor) {
this.setCursor(originalCursor);
}
this.setCursor(this.canGoForward() ? this.cursor + 1 : 0);
return this.$cursor;
};
LinkedList.prototype.canGoForward = function() {
return this.$set.length > 1 && this.cursor !== this.$set.length - 1;
};
LinkedList.prototype.canGoBack = function() {
return this.$set.length > 1 && this.cursor !== 0;
};
LinkedList.prototype.getPrevious = function(originalCursor) {
if (originalCursor) {
this.setCursor(originalCursor);
}
this.setCursor(this.canGoBack() ? this.cursor - 1 : this.$set.length - 1);
return this.$cursor;
};
function GerritFButtonUI($) {
var HAS_SCROLL_INTO_VIEW = (
typeof window !== 'undefined' &&
typeof HTMLElement.prototype.scrollIntoViewIfNeeded === 'function'
);
var $frame = $('<div />', { 'class': 'f-button__frame' });
var $container;
return {
node: $frame[0],
state: {
/**
* @property {Boolean}
* Whether to list only the files that have comments.
*/
commentedOnly: false,
/**
* @property {String}
*
* ID of the "selected" file so that we can highlight during keyboard
* navigation.
*
* Defaults to: @props.activeFile
*/
selectedFile: null,
},
props: {
files: [],
activeFile: null,
hideInUnifiedMode: false,
displayAsOverlay: false,
displayAsTree: true,
onToggleHideInUnifiedMode: Function.prototype,
onToggleDisplayAsOverlay: Function.prototype,
onToggleDisplayAsTree: Function.prototype,
},
/**
* @public
*/
mount: function(_container) {
$container = $(_container || document.body);
$container.addClass('gerrit--with-f-button');
$container.append($frame);
this.componentDidRender();
},
/**
* @public
*/
isMounted: function() {
return $frame.parent().length === 1;
},
/**
* Show or hide the F button frame.
*
* @param {?HTMLElement} [container=document.body]
* @param {?Object} options
* @param {Boolean} options.stealFocus
*/
toggle: function(container, options) {
if (this.isMounted()) {
this.unmount(container);
}
else {
this.mount(container);
if (options && options.stealFocus) {
this.stealFocus();
}
}
},
stealFocus: function() {
if (document.activeElement) {
document.activeElement.blur();
}
$frame.focus();
},
/**
* @public
*/
unmount: function() {
$frame.detach();
$container.removeClass('gerrit--with-f-button');
$container.removeClass('gerrit--with-f-button-overlay');
$container = null;
},
/**
* @public
*
* Update the F button with new parameters.
*
* @param {Object} props
*
* @param {Object[]} props.files
* The list of patch-set files with or without comment data.
*
* @param {String} props.activeFile
* File path of the file being currently browsed in gerrit.
*/
setProps: function(props) {
Object.keys(props).forEach(function(key) {
this.props[key] = props[key];
}.bind(this));
this.render();
},
/**
* @public
*
* Highlight the file following the currently selected file.
*/
selectNextFile: function() {
var list = new LinkedList($, $frame.find('.f-button-file'));
var $selectedFile = $(this.getSelectedFileDOMNode());
if ($selectedFile) {
list.setCursor($selectedFile);
}
var $next = list.getNext();
if ($next.length) {
this.selectFile($next[0].getAttribute('data-id'));
}
},
/**
* @public
*
* Highlight the file preceding the currently selected file.
*/
selectPreviousFile: function() {
var list = new LinkedList($, $frame.find('.f-button-file'));
var $selectedFile = $(this.getSelectedFileDOMNode());
if ($selectedFile) {
list.setCursor($selectedFile);
}
var $prev = list.getPrevious();
if ($prev.length) {
this.selectFile($prev[0].getAttribute('data-id'));
}
},
/**
* @public
*
* Activate the link of the selected file.
*/
activateSelectedFile: function() {
if (this.state.selectedFile) {
this.getSelectedFileDOMNode().querySelector('a').click();
}
},
/**
* @private
*/
componentDidRender: function() {
var activeFileDOMNode = this.getActiveFileDOMNode();
// Scroll the active row into view, very handy when the PS has many files.
if (activeFileDOMNode && HAS_SCROLL_INTO_VIEW) {
activeFileDOMNode.scrollIntoViewIfNeeded();
}
$frame.toggleClass('f-button__frame--list-view', !this.props.displayAsTree);
if ($container) {
$container.toggleClass('gerrit--with-f-button-overlay', this.props.displayAsOverlay);
}
},
/**
* @private
*/
render: function() {
var $files = this.renderFiles();
var $controls = this.renderControls();
$frame
.empty()
.append($files)
.append($controls)
.toggleClass('f-button__frame--commented-only', this.state.commentedOnly)
;
this.componentDidRender();
},
/**
* @private
*/
renderFiles: function() {
var $list = $('<div />', { 'class': 'f-button__table' });
var fileTree = TreeView(this.props.files);
$list.append(this.renderFileTree(fileTree, true));
return $list;
},
/**
* @private
*/
renderFileTree: function(tree, isRoot) {
var $list = $('<ol />', {
class: classSet({
'f-button-file__folder': true,
'f-button-file__folder--root': isRoot === true
})
});
// folders:
Object.keys(tree.children).sort().forEach(function(branch) {
var $children = this.renderFileTree(tree.children[branch]);
if (!$children) {
return null;
}
var $folderHeader = $('<header />', { class: 'f-button-file__folder-header' });
$folderHeader.append(
$('<span />', { class: 'f-button-file__icon f-button-icon__folder' })
);
$folderHeader.append($('<span />').text(branch + '/'));
return (
$('<li />')
.append($folderHeader)
.append($children)
.appendTo($list)
);
}.bind(this));
// files:
tree.items.forEach(function(file) {
if (this.state.commentedOnly && (!file.comments || !file.comments.length)) {
return null;
}
$list.append(this.renderFile(file));
}.bind(this));
return $list.children().length === 0 ? null : $list;
},
/**
* @private
*/
renderFile: function(file) {
var filePath = file.filePath;
var fileName = this.props.displayAsTree ?
file.filePath.split('/').slice(-1)[0] :
file.filePath
;
var hasComments = file.comments && file.comments.length > 0;
var $row = $('<li />', {
'data-id': file.filePath,
class: classSet({
'f-button-file': true,
'f-button-file--active': this.props.activeFile === filePath,
'f-button-file--selected': this.state.selectedFile === filePath,
'f-button-file--commented': hasComments
})
});
$row.append(
$('<span />', {
class: 'f-button-file__comment-count'
}).text(hasComments ? file.comments.length : '')
);
$row.append(
$('<span />', {
class: 'f-button-icon__file f-button-file__icon',
title: 'Copy filepath to clipboard'
}).bind('click', this.copyToClipboard.bind(this, filePath))
);
$row.append(
$('<a />', {
href: file.url,
class: 'f-button-file__link'
}).text(fileName)
);
return $row;
},
/**
* @private
*/
renderControls: function() {
var $controls = $('<div />', {
class: 'f-button__controls'
});
$('<label />')
.append($('<input />', { type: 'checkbox', checked: this.state.commentedOnly }))
.append($('<span />').text('Hide files with no comments'))
.appendTo($controls)
.bind('click', this.toggleCommented.bind(this))
;
$('<label />')
.append(
$('<input />', { type: 'checkbox', checked: this.props.hideInUnifiedMode })
.bind('change', this.toggleHideInUnifiedMode.bind(this))
)
.append($('<span />').text('Disable in Unified Diff view'))
.appendTo($controls)
;
$('<label />')
.append(
$('<input />', { type: 'checkbox', checked: this.props.displayAsOverlay })
.bind('change', this.toggleDisplayAsOverlay.bind(this))
)
.append($('<span />').text('Display as overlay'))
.appendTo($controls)
;
$('<label />')
.append(
$('<input />', { type: 'checkbox', checked: this.props.displayAsTree })
.bind('change', this.toggleDisplayAsTree.bind(this))
)
.append($('<span />').text('Display files as a tree'))
.appendTo($controls)
;
return $controls;
},
/** @private */
setState: function(nextState) {
Object.keys(nextState).forEach(function(key) {
this.state[key] = nextState[key];
}.bind(this));
this.render();
},
/** @private */
selectFile: function(filePath) {
if (this.props.activeFile === filePath) {
this.setState({ selectedFile: null });
}
else {
this.setState({ selectedFile: filePath });
}
},
/** @private */
getSelectedFile: function() {
return this.state.selectedFile || this.props.activeFile;
},
/** @private */
getSelectedFileDOMNode: function() {
var selectedFile = this.getSelectedFile();
return [].filter.call($frame.find('.f-button-file'), function(el) {
return el.getAttribute('data-id') === selectedFile;
})[0];
},
/** @private */
getActiveFileDOMNode: function() {
var activeFile = this.props.activeFile;
return [].filter.call($frame.find('.f-button-file'), function(el) {
return el.getAttribute('data-id') === activeFile;
})[0];
},
/**
* @private
*
* Copy a filepath to the clipboard.
*/
copyToClipboard: function(filePath/*, e*/) {
copyToClipboard(filePath);
},
/** @private */
toggleCommented: function() {
this.setState({ commentedOnly: !this.state.commentedOnly });
},
/** @private */
toggleHideInUnifiedMode: function(e) {
this.props.onToggleHideInUnifiedMode(e.target.checked);
},
/** @private */
toggleDisplayAsOverlay: function(e) {
this.props.onToggleDisplayAsOverlay(e.target.checked);
},
/** @private */
toggleDisplayAsTree: function(e) {
this.props.onToggleDisplayAsTree(e.target.checked);
},
};
}
var KC_F = 70;
var KC_J = 74;
var KC_K = 75;
var KC_RETURN = 13;
function GerritFButtonUIController($, core) {
var ui;
injectCSS(Styles);
window.addEventListener('keydown', function(e) {
var megaKey = e.shiftKey && e.ctrlKey && e.metaKey;
if (!megaKey) {
// don't show if we're not inspecting a patchset (or asked not to)
if (!core.isViewingPatchset() || (isInUnifiedMode() && shouldHideInUnifiedMode())) {
if (ui.isMounted()) {
ui.unmount();
}
return;
}
// don't steal focus from input widgets
else if (!shouldIntercept($, e.target)) {
return;
}
}
switch (e.keyCode || e.which) {
case KC_F:
// Some really pesky tuning here... the case is when we're focusing
// the editor node while the panel is already shown, we want to press
// MEGAKEY+f only to grant focus to the panel so that we can navigate
// to other files using the keyboard and do _not_ want to hide the panel
// as would've been done without this guard.
//
// So the formula is:
//
// [panel is visible] + [megakey] + [we're focusing editor]
//
// Double-pressing this combination _should_ hide the panel.
if (megaKey && ui.isMounted() && !shouldIntercept($, e.target)) {
ui.stealFocus();
}
// Allow cmd+f and ctrl+f through to permit search.
else if (e.ctrlKey || e.metaKey) {
return;
}
else {
ui.toggle(null, { stealFocus: megaKey });
}
break;
case KC_J:
if (ui.isMounted()) {
ui.selectNextFile();
}
break;
case KC_K:
if (ui.isMounted()) {
ui.selectPreviousFile();
}
break;
case KC_RETURN:
if (ui.isMounted()) {
ui.activateSelectedFile();
}
break;
}
}, false);
ui = GerritFButtonUI($);
ui.setProps({
hideInUnifiedMode: shouldHideInUnifiedMode(),
displayAsOverlay: shouldDisplayAsOverlay(),
displayAsTree: shouldDisplayAsTree(),
onToggleHideInUnifiedMode: function(checked) {
setSetting('GERRIT_F_BUTTON/HIDE_IN_UNIFIED_MODE', checked);
ui.setProps({ hideInUnifiedMode: shouldHideInUnifiedMode() });
},
onToggleDisplayAsTree: function(checked) {
setSetting('GERRIT_F_BUTTON/DISPLAY_AS_LIST', !checked);
ui.setProps({ displayAsTree: shouldDisplayAsTree() });
},
onToggleDisplayAsOverlay: function(checked) {
setSetting('GERRIT_F_BUTTON/DISPLAY_AS_OVERLAY', checked);
ui.setProps({ displayAsOverlay: shouldDisplayAsOverlay() });
}
});
return {
render: function(files, activeFile) {
ui.setProps({ files: files, activeFile: activeFile });
}
};
}
function setSetting(key, isOn) {
if (isOn) {
localStorage.setItem(key, '1');
}
else {
localStorage.removeItem(key);
}
}
function isInUnifiedMode() {
return !!document.querySelector('.gerritBody .unifiedTable');
}
function shouldHideInUnifiedMode() {
return localStorage.getItem('GERRIT_F_BUTTON/HIDE_IN_UNIFIED_MODE') === '1';
}
function shouldDisplayAsOverlay() {
return localStorage.getItem('GERRIT_F_BUTTON/DISPLAY_AS_OVERLAY') === '1';
}
function shouldDisplayAsTree() {
return localStorage.getItem('GERRIT_F_BUTTON/DISPLAY_AS_LIST') !== '1';
}
function shouldIntercept($, node) {
return !$(node).is('input, textarea');
}
var version = "2.1.2";
var NR_AJAX_CALLS = 2;
function GerritFButton() {
function parseContextFromURL(url) {
var ctx = {};
var matchChange = url.match(/^\/c\/(\d+)/);
var matchRevision = url.match(/^\/c\/\d+\/(\d+)/);
var matchFile = url.match(/^\/c\/\d+\/\d+\/(.+)/);
ctx.chNumber = matchChange ? matchChange[1] : null;
ctx.rvNumber = matchRevision ? matchRevision[1] : null;
ctx.activeFile = matchFile ? matchFile[1] : null;
return ctx;
}
/**
* Download the files for the given change/revision combo and any comments for
* them.
*
* @param {Number} chNumber
* @param {Number} rvNumber
* @param {Function} done
*
* @param {Object[]} done.files
* A hash of file-names and their info.
*
* @param {String} done.files[].url
* The URL for the file-diff page for this file.
*
* @param {Object[]} done.files[].comments
* The list of comments for this file.
*/
function fetch(chNumber, rvNumber, done) {
var files = [];
var BASE_URL = [ '/changes', chNumber, 'revisions', rvNumber ].join('/');
var callsDone = 0;
function set(filePath, item, value) {
var fileEntry = files.filter(function(entry) {
return entry.filePath === filePath;
})[0];
if (!fileEntry) {
fileEntry = { filePath: filePath };
files.push(fileEntry);
}
fileEntry[item] = value;
}
function tick() {
if (++callsDone === NR_AJAX_CALLS) {
done(files);
}
}
function getUrlForFile(filePath) {
return (
'/#/c/' + chNumber + '/' + rvNumber + '/' + discardLeadingSlash(filePath)
);
}
function getRemote(url, callback) {
window.$.ajax({
url: BASE_URL + url,
type: 'GET',
dataType: 'text',
success: function(resp) {
callback(JSON.parse(resp.substr(")]}'".length)));
tick();
}
});
}
getRemote('/files', function(rvFiles) {
Object.keys(rvFiles).forEach(function(_filePath) {
var filePath = discardLeadingSlash(_filePath);
set(filePath, 'url', getUrlForFile(filePath));
});
});
getRemote('/comments', function(rvFileComments) {
Object.keys(rvFileComments).forEach(function(_filePath) {
var filePath = discardLeadingSlash(_filePath);
set(filePath, 'comments', rvFileComments[filePath]);
});
});
}
return {
install: function(Gerrit, $) {
var context, cachedFiles;
var ui = GerritFButtonUIController($, {
isViewingPatchset: function() {
return !!context.chNumber;
}
});
// @event 'showchange'
//
// This will be triggered everytime the change's "landing" page is
// visited.
//
// See https://gerrit-review.googlesource.com/Documentation/js-api.html#self_on
//
// @param chInfo
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
//
// @param rvInfo
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
Gerrit.on('showchange', function(chInfo, rvInfo) {
fetchFilesAndRender(chInfo._number, rvInfo._number);
});
// @event 'history'
//
// This is triggered everytime a new page in the Gerrit UI is visited;
// we are interested with the visits to the file-diff pages because we'd
// like to highlight the currently viewed file.
//
// See https://gerrit-review.googlesource.com/Documentation/js-api.html#self_on
Gerrit.on('history', function(token) {
context = parseContextFromURL(token);
if (context.chNumber) {
// This happens if the initial URL is not the change's landing page, but
// instead a file-diff page; the "showchange" event would not be emitted
// in this case and there's no way to get the change/revision information
// but from the URL.
if (!cachedFiles) {
fetchFilesAndRender(context.chNumber, context.rvNumber);
}
else {
render();
}
}
else { // no longer in a change? untrack the downloaded file listing
cachedFiles = null;
}
});
console.log('gerrit-f-button: version %s.', version);
function fetchFilesAndRender(chNumber, rvNumber) {
fetch(chNumber, rvNumber, function(files) {
cachedFiles = files;
render();
});
}
function render() {
ui.render(cachedFiles, context.activeFile);
}
}
};
}
// HTML tests
if (typeof window !== 'undefined' && typeof window.GerritFButton !== 'undefined') {
window.GerritFButton.Core = GerritFButton;
window.GerritFButton.Styles = Styles;
window.GerritFButton.UI = GerritFButtonUI;
window.GerritFButton.injectCSS = injectCSS;
}
// mocha tests
else if (typeof module !== 'undefined') {
/* eslint-disable */
exports.Core = GerritFButton;
exports.UI = GerritFButtonUI;
exports.TreeView = TreeView;
/* eslint-enable */
}
// Gerrit env
else {
var gerritFButton = new GerritFButton();
var poller, timeout;
timeout = setTimeout(function() {
// note: this guard is not necessary outside of grease-monkey's context since
// the timeout will be cleared if the poller's test succeeds.
if (!gerritFButton.installed) {
console.error(
'gerrit-f-button: one of window.Gerrit or window.jQuery is not present;',
'plugin will not work.'
);
}
// for some reason, this isn't working in Greasemonkey
poller = clearInterval(poller);
}, 30000);
poller = setInterval(function() {
if (window.Gerrit && window.jQuery) {
gerritFButton.install(window.Gerrit, window.jQuery);
// for some reason, this isn't working in Greasemonkey
poller = clearInterval(poller);
timeout = clearTimeout(timeout);
}
}, 250);
}
}());