/*
* CDDL HEADER START
*
* The contents of this file are subject to the terms of the
* Common Development and Distribution License (the "License").
* You may not use this file except in compliance with the License.
*
* See LICENSE.txt included in this distribution for the specific
* language governing permissions and limitations under the License.
*
* When distributing Covered Code, include this CDDL HEADER in each
* file and include the License file at LICENSE.txt.
* If applicable, add the following below this CDDL HEADER, with the
* fields enclosed by brackets "[]" replaced with your own identifying
* information: Portions Copyright [yyyy] [name of copyright owner]
*
* CDDL HEADER END
*/
/*
* Copyright (c) 2009, 2010, Oracle and/or its affiliates. All rights reserved.
* Portions Copyright 2011, 2012, Jens Elkner.
*/
// YUI.GlobalConfig = { fetchCSS: false };
/* Create a new YUI instance and attach modules + deps always needed */
var Y = YUI().use('node-style', 'node-screen', 'node-event-delegate', 'event-key');
// lets fake cssbutton entry to avoid useless downloads - it is a css-only
// module, already integrated into our style.css
YUI.Env._loaded[YUI.version]['cssbutton'] = true;
if (typeof OpenGrok != 'undefined') {
OpenGrok._OpenGrok = OpenGrok;
}
var OpenGrok = function() {
var O = this,
instanceOf = function(o, type) {
return (o && o.hasOwnProperty && (o instanceof type));
};
if (!(instanceOf(O, OpenGrok))) {
O = new OpenGrok();
} else {
// set up the core environment
O._init();
}
O.instanceOf = instanceOf;
return O;
};
(function() {
var prop, proto = {
/** Initialize this OpenGrok instance */
_init: function() {
var O = this;
O.version = Y.one('html > head > meta[name=generator]')
.getAttribute('content');
O.constructor = OpenGrok;
},
/** Array of functions to be called when the page is completely loaded
* (i.e. incl. images etc.). Default: [] */
pageReady: [], // see mast.jsp
/** Array of functions to be called when the DOM of the page got loaded.
* Default: [] */
domReady: [], // see mast.jsp
/** The revision string of the shown document prefixed with 'r='.
* Default: null */
rev: null, // see mast.jsp, possible overwrite by diff.jsp
/** Link to the web applications home. Default: # */
linkHome: "#",
/** Link to the most recent version of the shown document. Default: null */
linkXref: null, // see mast.jsp
/** Link to the history of the shown document. Append <var>O.rev</var>
* parameter to fetch a certain revision. Default: null */
linkHistory: null, // see mast.jsp
/** Download link to the file of the shown document. Append <var>O.rev</var>
* parameter to fetch a certain revision. Default: null */
linkDownload: null, // see mast.jsp
/** Boolean value to indicate, whether annotations are available
* for the most recent version of the shown document. Default: false */
hasAnnotations: false, // see mast.jsp
/** Boolean value to incidcate, whether the shown document already
* contains all annotations. Default: false */
annotated: false, // see mast.jsp
/** Boolean value to indicate, whether a list of files for the
* changeset corresponding to the shown document is available.
* Default: false */
hasFileList: false, // see history.jsp
/** Boolean value to indicate, whether the shown document is a directory
* listing. Default: false */
isDir: false, // see mast.jsp
/** Boolean value to indicate, whether the shown document contains
* cross reference content. Default: false */
isXref: false, // see mast.jsp
/** The list of all available project. Default: [] */
projects: [], // see menu.jspf
/** Base path for cross file links (i.e. without the project part).
* Default: null */
xrefPath: null, // see menu.jspf
/** A list with symbols of the shown code. Default: []
* @see JFlexXref#writeSymbolTable() */
symlist: [] // see mast.jsp + crossfile
};
OpenGrok.prototype = proto;
// bootstrap
for (prop in proto) {
if (proto.hasOwnProperty(prop)) {
OpenGrok[prop] = proto[prop];
}
}
// set up the environment
OpenGrok._init();
// Support the CommonJS method for exporting our single global
if (typeof exports == 'object') {
exports.OpenGrok = OpenGrok;
}
}());
/** Make an instance available to the document beeing loaded */
var O = new OpenGrok();
/**
* Invoke all functions of document.pageReady .
*/
Y.on('load', function() {
for(var i in O.pageReady) {
O.pageReady[i]();
}
});
/**
* Invoke all functions of document.domReady .
*/
Y.on('domready', function() {
for(var i in O.domReady) {
O.domReady[i]();
}
});
OpenGrok.prototype.getCssBase = function() {
if (this.cssBase) {
return this.cssBase;
}
var tmp = Y.config.doc.styleSheets;
if (!tmp) {
return "";
}
this.cssBase = "";
this.cssScheme = "";
for (var i=0; i < tmp.length; i++) {
var parts = tmp[i].href.split('/');
var last = parts.pop();
if (last === 'style.css' || last === 'style-min.css') {
this.cssScheme = parts.pop();
this.cssBase = parts.join('/');
break;
}
}
return this.cssBase;
};
OpenGrok.prototype.getCssScheme = function() {
if (!this.cssScheme) {
this.getCssBase();
}
return this.cssScheme;
};
OpenGrok.prototype.normalizeCssURI = function(uri) {
if ((!uri && !Y.Lang.isString(uri)) || uri === 'none') {
return uri;
}
var prefix = "";
var suffix = "";
var tmp = uri.trim();
if (tmp.indexOf('url(') === 0) {
prefix = 'url(';
suffix = ')';
tmp = tmp.substr(4,uri.length-5).trim();
}
if (tmp.indexOf('"') == 0 || tmp.indexOf("'") == 0) {
prefix += "'";
suffix = "'" + suffix;
tmp = tmp.substr(1,tmp.length-2);
}
if (tmp.indexOf('http://') == 0 || tmp.indexOf('https://') == 0
|| tmp.indexOf('/') == 0)
{
return uri;
}
return prefix + this.getCssBase() + '/' + this.getCssScheme() + '/' + tmp + suffix;
};
OpenGrok.prototype.createLinenums = function() {
var node, dstnode = Y.one('#linenums');
if (!dstnode || dstnode.hasChildNodes() || O.doingLinenums) {
return;
}
O.doingLinenums = true;
var num = O.lines || Y.one('#lines').get('childNodes.length');
// ID nodes need to be separate, since display:none usually removes
// the node from the DOM and thus lines are not addressable by
// lineno. Visibility:hidden doesn't work as well - just makes the
// box transparent but keeps its width ...
var txtId = '<div id="nids">';
var txtNum = '<div id="nums">';
for (var i=1; i <= num; i++) {
txtId += '<a name="' + i + '">\n</a>'; // force a visible line
// works, but style a single num becomes tricky :(
// txtNum += i + '&nbsp;\n';
txtNum += '<div>' + i + '</div>';
}
txtId += '</div>';
txtNum += '</div>';
node = Y.Node.create(txtId + txtNum);
dstnode.insert(node);
delete(O.doingLinenums);
dstnode.setStyle('display', 'inline-block');
txtNum = Y.config.win.location.hash;
if (txtNum) {
// need to redo since on the first try the ID is not available
Y.config.win.location.hash = txtNum;
}
};
OpenGrok.prototype.fetchAnnotations = function() {
if (O.requestA || O.fetchingA) {
return;
}
var dstnode = Y.one('#annos');
if (!dstnode || dstnode.hasChildNodes()) {
return;
}
Y.use('io-base','json-parse', 'tooltip', function(Y) {
O.fetchingA = true;
// Prefix.XREF_P, Prefix.JSON_A
var uri = O.linkXref.replace("/xref/","/json/annotate/");
if (Y.Lang.isValue(O.rev)) { // via diff.jspf, mast.jsp
uri += '?' + O.rev;
}
function complete(id, req) {
var i, data, revs, lines, l2r, a, resp , L = Y.Lang;
var cleanup = function (msg) {
if (msg) {
alert(msg);
}
delete(O.requestA, O.fetchingA);
Y.one('#annotate').setStyles(button.getData('bg'));
}
// simple data conversion and check
try {
resp = Y.JSON.parse(req.responseText);
} catch (e) {
cleanup(e.name + ": " + e.message);
return;
}
data = resp.data;
if (L.isValue(resp.errors)) {
cleanup(resp.errors.join('<br/>'));
return;
}
if (!(L.isValue(data) && L.isValue(data.a)
&& L.isArray(data.a.revs)
&& L.isValue(data.a.line2rev)))
{
cleanup("data format error");
return;
}
// prepare for later use
a = data.a;
revs = a.revs;
l2r = a.line2rev;
O.a = {
'uri' : a.uri,
'lines' : a.lines,
'revs' : revs
};
if (L.isValue(data.userPage)) {
if (L.isValue(data.userPage.link)) {
O.a.ulink = data.userPage.link;
}
if (L.isValue(data.userPage.suffix)) {
O.a.usfx = data.userPage.suffix;
}
}
// generate rev and author column
var start, stop = 1, len=l2r.length, rid;
l2r.push(a.lines+1, l2r[len-1]); // add the upper limit
len += 2;
var rcol = '<div id="revision">';
var acol = '<div id="author">';
for (i=0; i < len; i += 2) {
start = stop;
stop = l2r[i];
for (var n=start; n < stop; n++) {
rcol += '<div name="' + rid + '">' + rev.rev + '</div>';
acol += '<div name="' + rid + '">' + rev.author + '</div>';
}
rid = l2r[i+1];
rev = revs[rid];
}
rcol += '</div>';
acol += '</div>';
// insert columns
var node = Y.Node.create(rcol + acol);
delete(rcol, acol);
if (dstnode.getStyle('display') !== 'none') {
dstnode.setStyle('display', 'none');
}
dstnode.insert(node);
dstnode.setStyle('display','inline-block');
var a = O.a;
var tt = new Y.Tooltip({
triggerNodes : '#revision > div',
showDelay: 150,
autoHideDelay : 6000,
delegate : '#revision',
shim : false,
zIndex : 2000,
CONTENT_TEMPLATE : null,
content: function(node) {
var rid = node.getAttribute('name');
if (rid) {
var info = a.revs[rid];
if (info) {
return info.msg;
}
}
return "";
}
});
tt.render();
if (a.uri) {
Y.one('#revision').delegate('click', function(e) {
e.halt();
var rid = e.target.getAttribute('name');
if (!rid) {
return;
}
var link = a.uri;
var info = a.revs[rid];
if (info) {
link += '?' + Y.QueryString.stringify({'r': info.rev});
Y.config.win.location = link;
}
}, 'div');
}
if (a.ulink) {
Y.one('#author').delegate('click', function(e) {
e.halt();
var rid = e.target.getAttribute('name');
if (!rid) {
return;
}
var link = a.ulink
+ encodeURIComponent(a.revs[rid].author);
if (a.usfx) {
link += a.usfx;
}
Y.config.win.open(link, 'OpenGrok_u' + rid);
}, 'div');
}
cleanup();
};
Y.on('io:complete', complete, Y);
var button = Y.one('#annotate');
button.setData('bg', {
backgroundImage: O.normalizeCssURI(button.getStyle('backgroundImage')),
backgroundPosition: button.getStyle('backgroundPosition'),
verticalAlign: button.getStyle('verticalAlign'),
width: button.getStyle('width'),
height: button.getStyle('height'),
display: button.getStyle('display'),
before: button.getStyle('content')
});
button.setStyles({ /* unfortunately we can't put it into combined.png */
backgroundImage: O.normalizeCssURI('url("img/loading-18x18.gif")'),
backgroundPosition: '0 0',
verticalAlign: 'middle',
width: '18px',
height: '18px',
display: 'inline-block',
});
// fetch
O.requestA = Y.io(uri);
});
};
/**
* Init after DOM has been loaded.
* @see menu.jspf
*/
OpenGrok.prototype.domReadyMenu = function() {
var sbox = Y.one('#sbox');
sbox.one('#clearSearchBox').on('click', function() {
sbox.all('input[class=q]').set('value', '');
});
sbox.one('#helpButton').on('click', function() {
Y.config.win.open('help.jsp');
});
sbox.one('#selectAllProjects').on('click', function() {
/** Select all projects in the project selection list. */
sbox.one('#project').get('options').set('selected', true);
});
sbox.one('#invertAllProjects').on('click', function() {
/** Invert the list of selected projects in the project selection list. */
sbox.one('#project').get('options').each(function() {
this.set('selected', !this.get('selected'));
});
});
/** Redirect to the project page of the first selected project. */
var goFirstProject = function(e) {
var selected = sbox.one('#project').get('value');
if (!selected) {
// TODO: Use a customized dialog
alert('Please select a project!');
return;
}
e.halt();
Y.config.win.location = O.xrefPath + '/' + selected;
};
sbox.one('#project').once('dblclick', function(e) {
// selects event.get('target') before this one gets called, so:
goFirstProject(e);
});
Y.on('key', function(e) {
if (sbox.one('#q').get('value').trim() == ''
&& sbox.one('#defs').get('value').trim() == ''
&& sbox.one('#refs').get('value').trim() == ''
&& sbox.one('#path').get('value').trim() == ''
&& sbox.one('#hist').get('value').trim() == '')
{
goFirstProject(e);
} else {
sbox.submit();
}
}, '#project', 'enter');
};
/**
* Init after DOM has been loaded.
*
* @see history.jsp
*/
OpenGrok.prototype.domReadyHistory = function() {
var divList = null;
var radioList = null;
var button = Y.one('#expand').get('parentNode');
var tbody = Y.one('#revisions > tbody');
if (O.hasFileList) {
O.filelistExpanded = false;
var expandLabel = ' + Modified files ';
var collapseLabel = ' - Modified files ';
var x = button.get('childNodes').pop();
if (x && x.get('nodeName') === '#text') {
x.set('text', expandLabel);
} else {
button.appendChild(Y.node.create(expandLabel));
}
button.on('click', function(e) {
/** Show/Hide the list of modified files of a directory in history
* view. */
e.halt();
var what = O.filelistExpanded ? 'none' : 'block';
O.filelistExpanded = !O.filelistExpanded;
var label = O.filelistExpanded ? collapseLabel : expandLabel;
button.get('childNodes').pop().set('text', label);
if (!divList) {
divList = tbody.all('> tr > td > div');
}
divList.setStyle('display', what);
return true;
});
button.setAttribute('title',
'Click to expand/collapse all changeset file lists');
tbody.delegate('click', function(e) {
e.halt();
var div = this.next('div');
var what = div.getStyle('display') == 'block' ? 'none' : 'block';
div.setStyle('display', what);
}, '> tr > td > p');
} else {
button.setStyle('display', 'none');
}
var togglediffs = function(e) {
if (e) {
e.stopPropagation();
}
/** Toggle revision radio buttons in the history view. */
var cr2 = false;
var cr1 = false;
if (!radioList) {
radioList = tbody.all('> tr > td > input[type=radio]');
}
radioList.each(function() {
if (this.get('name') === 'r1') {
if (this.get('checked')) {
cr1 = true;
return true;
}
this.set('disabled', (cr2) ? '' : 'true');
} else if (this.get('name') === 'r2') {
if (this.get('checked')) {
cr2 = true;
return true;
}
this.set('disabled', (!cr1) ? '' : 'true');
}
});
};
// start state should ALWAYS be: first row: r1 hidden, r2 checked ;
// second row: r1 clicked, (r2 hidden)(optionally)
// I cannot say what will happen if they are not like that, togglediffs
// will go mad !
if (!O.isDir) {
Y.on('key', function(e) {
if (e.metaKey || e.ctrlKey) {
e.halt();
Y.one('#content form').submit();
return true;
} else {
return false;
}
}, 'body', 'c');
Y.one('#revisions > thead > tr > th > button').setAttribute('title',
'Shortcut: ' + (Y.UA.os === 'macintosh' ? 'cmd' : 'ctrl') + ' + c');
tbody.delegate('click', togglediffs, '> tr > td > input[type=radio]');
togglediffs();
}
};
/**
* Init after DOM has been loaded.
*
* @see mast.jsp
*/
OpenGrok.prototype.domReadyMast = function() {
if (!Y.config.win.location.hash) {
Y.one('#content').focus();
}
Y.one('#home').get('parentNode').on('click', function(e) {
e.halt();
Y.config.win.location = O.linkHome;
return false;
});
var button = Y.one('#history').get('parentNode');
if (!Y.Lang.isValue(O.linkHistory)) {
button.setStyle('display', 'none');
} else {
button.on('click', function(e) {
e.halt();
Y.config.win.location = O.linkHistory;
return false;
});
}
button = Y.one('#annotate').get('parentNode');
if (!O.hasAnnotations) {
button.setStyle('display', 'none');
} else if (O.annotated) {
// old style via ?a=true : annotations already there and initially shown
var blameNodes = Y.all('#lines .blame');
button.on('click', function(e) {
e.halt();
/** Make annotations [in]visible. */
var what = blameNodes.item(0).getStyle('display') === 'none'
? 'inline-block' : 'none';
blameNodes.each(function() {
this.setStyle('display', what);
});
});
Y.use('tooltip', function(Y) {
var tt = new Y.Tooltip({
triggerNodes : '.blame .r',
showDelay: 150,
autoHideDelay : 6000,
delegate : "#lines",
shim : false,
zIndex : 2000,
CONTENT_TEMPLATE : null
});
tt.render();
});
} else {
// better: fetch annotations on demand
button.on('click', function(e) {
e.halt();
O.fetchAnnotations();
var aNode = Y.one('#annos');
if (aNode.getStyle('display') === 'none') {
aNode.setStyle('display', 'inline-block');
} else {
aNode.setStyle('display', 'none');
}
});
}
button = Y.one('#line').get('parentNode');
if (O.isXref && !O.isDir) {
O.createLinenums();
button.on('click', function() {
var dstnode = Y.one('#nums');
if (dstnode.getStyle('display') === 'none') {
dstnode.setStyle('display', 'inline-block');
} else {
dstnode.setStyle('display', 'none');
}
});
Y.one('#linenums').delegate('click', function(e) {
e.halt();
Y.config.win.location.hash = '#' + e.target.get('text');
}, 'div');
} else {
button.setStyle('display', 'none');
}
button = Y.one('#defbox').get('parentNode');
if (!Y.Lang.isArray(O.symlist) || O.symlist.length == 0) {
button.setStyle('display', 'none');
} else {
button.on('click', function() {
if (!Y.Lang.isValue(O.panel)) {
Y.use('symbols-panel', function(Y) {
var panel = new Y.SymbolsPanel({
srcNode: Y.SymbolsPanel.createSrcNode(O.symlist),
zIndex: 50,
visible: false,
shim: false,
render: true,
fillHeight: 'body'
});
O.panel = panel;
panel.show();
});
} else {
var panel = O.panel;
if (panel.get('visible')) {
panel.hide();
} else {
panel.show();
}
}
});
}
button = Y.one('#download').get('parentNode');
if (!Y.Lang.isValue(O.linkDownload)) {
button.setStyle('display', 'none');
} else {
button.on('click', function(e) {
var link = O.linkDownload;
if (Y.Lang.isValue(O.rev)) { // diff.jspf, mast.jsp
link += '?' + O.rev;
}
e.halt();
Y.config.win.location = link;
return false;
});
}
if (O.linkHome != '/source/') {
button = Y.one('#src');
if (button) {
// 'click' works for left mouse button, only. 'mouseover' has the
// advantage wrt. 'mousedown', that the link tooltip shows the
// adjusted URL as well.
(function() {
var checkLink = function (e) {
var url = e.target.getAttribute('href');
if (url.substr(0, 8) === '/source/') {
e.target.setAttribute('href', O.linkHome + url.substr(8));
}
return true;
};
button.delegate('mouseover', checkLink , 'a');
button.delegate('keydown', checkLink , 'a');
}());
}
}
};
OpenGrok.prototype.domReadyStatus = function() {
/* Add all modules used anywhere in this script via Y.use(...) or
* requires: [ ... ]. Thus we force loading all of them and can determine,
* which modules are sufficient to satisfy this script.
*/
var node = Y.one('#yui_modules');
if (!node) {
return;
}
// what we currently use
Y.use(// opengrok.js
'node-style', 'node-screen', 'node-event-delegate', 'event-key',
// opengrok-tooltip.js
'widget', 'widget-position', 'widget-stack',
// opengrok-symbols-panel.js
'widget', 'widget-position', 'widget-stack',
'widget-autohide', 'widget-buttons', 'widget-stdmod',
'dd-plugin', 'escape',
// annotations
'io-base', 'json-parse',
// our own extensions as well
'tooltip', 'symbols-panel');
var copy = [];
for (prop in Y.Env._attached) {
copy.push(prop);
}
copy.sort();
var txt = copy.join(', ');
// let's find out unused modules - attach all
Y.use('*');
var copy2 = [];
for (prop in Y.Env._attached) {
copy2.push(prop);
}
copy2.sort();
var unused = [];
var idx = copy.length - 1;
for (var i=copy2.length-1; i >= 0; i--) {
if (idx >= 0 && copy2[i] === copy[idx]) {
copy.pop();
idx--;
} else {
unused.push(copy2[i]);
}
}
if (unused.length > 0) {
txt += "<br/><b>Unused:</b> " + unused.reverse().join(", ");
}
node.set('innerHTML', txt);
};
/**
* Init after complete page (i.e. incl. images etc.) has been loaded.
*
* @see mast.jsp
*/
OpenGrok.prototype.pageReadyMast = function() {
O.adjustContent = 0;
/**
* Resize the element with the ID 'content' so that it fills the whole
* browser window (i.e. the space between the header and the bottom of the
* window) and thus get rid off the scrollbar in the page header.
*/
var resize = function() {
if (O.adjustContent !== 0) {
var h = Y.one('#whole_header');
Y.one('#content').setStyles({
top : h.getY() + h.get('offsetHeight') - 1,
bottom : 0
});
}
};
if (Y.one('#whole_header') && Y.one('#content')) {
O.adjustContent = 1;
resize();
}
Y.on('resize', resize);
};
OpenGrok.prototype.getTotalOffset = function(node) {
if (!node) {
return 0;
}
var st = node.get('offsetTop');
var x = node.get('offsetParent');
while (x) {
st += x.get('offsetTop');
x = x.get('offsetParent');
}
return st;
};
/**
* Init after DOM has been loaded.
* @see diff.jsp
*/
OpenGrok.prototype.domReadyDiff = function() {
var content = Y.one('#content'); // keep for scrolling
if (!content || !content.hasChildNodes()) {
return;
}
var tbody = content.one('#dtwdiff > tbody'); // we are interested in wdiff's only
if (!tbody || !tbody.hasChildNodes()) {
return;
}
tbody.delegate('click', function(e) {
e.halt();
var id = this.getAttribute('id');
var showSource = id.indexOf("hi_") == 0;
var block = tbody.one((showSource ? "#hs_" : "#hi_") + id.substr(3));
if (!block) {
return;
}
if (e.metaKey || e.ctrlKey) {
// expand or (with alt key pressed) collapse all
var dtl_action = e.altKey ? 'table-row' : 'none';
var dte_action = e.altKey ? 'none' : 'table-row';
var offset = e.altKey && !showSource
? e.clientY
: (O.getTotalOffset(this) - content.get('scrollTop'));
tbody.all('> tr.dtl').setStyle('display', dtl_action);
tbody.all('> tr.dte').setStyle('display', dte_action);
block = tbody.one((e.altKey ? "#hi_" : "#hs_") + id.substr(3));
var st = O.getTotalOffset(block);
if (e.altKey && !showSource) {
st += block.get('offsetHeight')/2;
}
content.set('scrollTop', st - offset);
} else if (showSource && e.altKey) {
// preserve the position of the row which follows "this", i.e.
// the bottom of the row to show should have the same y position
// as the bottom of the row to hide
var st = content.get('scrollTop') - this.get('offsetHeight');
this.setStyle('display', 'none');
block.setStyle('display', 'table-row');
content.set('scrollTop', st + block.get('offsetHeight'));
} else if (showSource) {
// just replace, i.e. the top of the row to show should have the same
// y position as the top of the row to hide
this.setStyle('display', 'none');
block.setStyle('display', 'table-row');
} else if (!showSource) {
// hide the row with unchanged source lines and try to position the
// row to show where the click occured wrt. its y position
var st = O.getTotalOffset(this);
this.setStyle('display', 'none');
block.setStyle('display', 'table-row');
content.set('scrollTop', st - e.clientY + block.get('offsetHeight')/2);
}
}, function(target, e) {
var id = target.getAttribute('id');
return id && (id.indexOf('hi_') == 0 || id.indexOf('hs_') == 0);
});
var ctrlkey = Y.UA.os === 'macintosh' ? 'cmd' : 'ctrl';
var eol = Y.UA.gecko && Y.UA.gecko < 12.0 ? ' ; ' : '\n';
tbody.all('> tr.dtl > td').setAttribute('title',
'click .. expand downwards/collapse' + eol +
'click + alt .. expand upwards/collapse' + eol +
'click + ' + ctrlkey + ' .. expand all' + eol +
'click + ' + ctrlkey + ' + alt .. collapse all');
};
// vim: set filetype=javascript ts=4