1 // © 2014 Michael Stapelberg
3 // Opens a WebSocket connection to Debian Code Search to send and receive
4 // search results almost instantanously.
6 // NB: All of these constants needs to match those in cmd/dcs-web/querymanager.go
7 var packagesPerPage = 5;
8 var resultsPerPackage = 2;
10 var animationFallback;
11 var showConnectProgress;
13 var websocket_url = window.location.protocol.replace('http', 'ws') + '//' + window.location.host + '/instantws';
14 var connection = new ReconnectingWebSocket(websocket_url);
16 var queryDone = false;
18 // fatal (bool): Whether all ongoing operations should be cancelled.
20 // permanent (bool): Whether this message will be displayed permanently (e.g.
21 // “search results incomplete” vs. “trying to reconnect in 3s…”)
23 // unique_id (string): If non-null, only one message of this type will be
24 // displayed. Can be used to display only one notification about incomplete
25 // search results, regardless of how many backends the server returns as
28 // message (string): The human-readable error message.
29 function error(fatal, permanent, unique_id, message) {
30 if (unique_id !== null && $('#errors div[data-uniqueid=' + unique_id + ']').size() > 0) {
34 progress(100, false, 'Error: ' + message);
38 var div = $('<div class="alert alert-' + (permanent ? 'danger' : 'warning') + '" role="alert"></div>');
39 if (unique_id !== null) {
40 div.attr('data-uniqueid', unique_id);
43 $('#errors').append(div);
47 // Setting percentage to 0 means initializing the progressbar. To display some
48 // sort of progress to the user, we’ll set it to 10%, so any actual progress
49 // that is communicated from the server will need to be ≥ 10%.
51 // Setting temporary to true will reset the text to the last non-temporary text
52 // upon completion (which is a call with percentage == 100).
53 function progress(percentage, temporary, text) {
55 if (percentage == 0) {
56 $('#progressbar span').text(text);
57 $('#progressbar .progress-bar').css('width', '10%');
58 $('#progressbar .progress-bar').addClass('progress-active');
59 $('#progressbar').show();
62 $('#progressbar span').text(text);
64 $('#progressbar').data('old-text', text);
67 $('#progressbar .progress-bar').css('width', percentage + '%');
68 if (percentage == 100) {
69 $('#progressbar .progress-bar').removeClass('progress-active');
71 $('#progressbar span').text($('#progressbar').data('old-text'));
77 // Animates the search form from the middle of the page to the top right.
78 function animateSearchForm() {
79 // A bit hackish: we rip the search form out of the DOM and use
80 // position: absolute, so that we can later animate it across the page
81 // into the top right #searchbox div.
82 var sf = $('#searchform');
83 var pos = sf.position();
84 $('#searchbox .formplaceholder').css({ width: sf.width(), height: sf.height() });
85 pos.position = 'absolute';
86 $('#searchdiv .formplaceholder').css('height', sf.height());
88 sf.appendTo('#content');
91 sf.animate($('#searchbox').position(), 'fast', function() {
92 $('#searchdiv').hide();
94 $('#searchform input[name=q]').blur();
98 function showResultsPage() {
99 $('#results li').remove();
100 $('#normalresults').show();
101 $('#progressbar').show();
102 $('#options').hide();
103 $('#packageshint').hide();
104 $('#pagination').text('');
105 $('#perpackage-pagination').text('');
108 function sendQuery() {
110 $('#packages').text('');
111 $('#errors div.alert-danger').remove();
113 "Query": "q=" + encodeURIComponent(searchterm),
115 connection.send(JSON.stringify(query));
116 document.title = searchterm + ' · Debian Code Search';
117 var entries = localStorage.getItem("autocomplete");
118 if (entries === null) {
119 localStorage["autocomplete"] = JSON.stringify([searchterm]);
121 entries = JSON.parse(entries);
122 if (entries.indexOf(searchterm) === -1) {
123 entries.push(searchterm);
125 localStorage["autocomplete"] = JSON.stringify(entries);
129 progress(0, false, 'Checking which files to grep…');
133 connection.onopen = function() {
134 clearTimeout(showConnectProgress);
135 $('#searchform input').attr('disabled', false);
137 // The URL dictates a search query, so start it.
139 (window.location.pathname.lastIndexOf('/results/', 0) === 0 ||
140 window.location.pathname.lastIndexOf('/perpackage-results/', 0) === 0)) {
141 var parts = new RegExp("results/([^/]+)").exec(window.location.pathname);
142 searchterm = decodeURIComponent(parts[1]);
146 $('#searchform').off('submit').on('submit', function(ev) {
147 searchterm = $('#searchform input[name=q]').val();
149 history.pushState({ searchterm: searchterm, nr: 0, perpkg: false }, 'page ' + 0, '/results/' + encodeURIComponent(searchterm) + '/page_0');
153 // This is triggered when the user navigates (e.g. via back button) between
154 // pages that were created using history.pushState().
155 $(window).off('popstate').on('popstate', function(ev) {
156 var state = ev.originalEvent.state;
158 // Restore the original page.
159 $('#normalresults, #perpackage, #progressbar, #errors, #packages, #options').hide();
160 $('#searchdiv').show();
161 $('#searchdiv .formplaceholder').after($('#searchform'));
162 $('#searchform').css('position', 'static');
163 restoreAutocomplete();
165 if (!$('#normalresults').is(':visible') &&
166 !$('#perpackage').is(':visible')) {
169 // The following are necessary because we don’t send the query
170 // anew and don’t get any progress messages (the final progress
171 // message triggers displaying certain elements).
172 $('#packages, #errors, #options').show();
174 $('#enable-perpackage').prop('checked', state.perpkg);
177 loadPerPkgPage(state.nr);
185 connection.onerror = function(e) {
186 // We could display an error, but since the page is supposed to fall back
187 // gracefully, why would the user be concerned if the search takes a tiny
188 // bit longer than usual?
189 // error(false, true, 'websocket_broken', 'Could not open WebSocket connection to ' + e.target.URL);
191 // Some transparent proxies don’t support WebSockets, e.g. Orange (big
192 // mobile provider in Switzerland) removes “Upgrade: ” headers from the
193 // HTTP requests. Therefore, we try to use wss:// if the connection was
194 // being made via ws://.
196 connection.url = websocket_url.replace('ws://', 'wss://');
201 connection.onclose = function(e) {
202 // XXX: ideally, we’d only display the message if the reconnect takes longer than, say, a second?
203 var msg = error(false, false, null, 'Lost connection to Debian Code Search. Reconnecting…');
204 $('#searchform input').attr('disabled', true);
206 var oldHandler = connection.onopen;
207 connection.onopen = function() {
208 $('#searchform input').attr('disabled', false);
210 connection.onopen = oldHandler;
221 function addSearchResult(results, result) {
223 // NB: All of the following context lines are already HTML-escaped by the server.
224 context.push(result.Ctxp2);
225 context.push(result.Ctxp1);
226 context.push('<strong>' + result.Context + '</strong>');
227 context.push(result.Ctxn1);
228 context.push(result.Ctxn2);
229 // Remove any empty context lines (e.g. when the match is close to the
230 // beginning or end of the file).
231 context = $.grep(context, function(elm, idx) { return $.trim(elm) != ""; });
232 context = context.join("<br>").replace("\t", " ");
234 // Split the path into source package (bold) and rest.
235 var delimiter = result.Path.indexOf("_");
236 var sourcePackage = result.Path.substring(0, delimiter);
237 var rest = result.Path.substring(delimiter);
239 // Append the new search result, then sort the results.
240 results.append('<li data-ranking="' + result.Ranking + '"><a href="/show?file=' + encodeURIComponent(result.Path) + '&line=' + result.Line + '"><code><strong>' + sourcePackage + '</strong>' + escapeForHTML(rest) + '</code></a><br><pre>' + context + '</pre><small>PathRank: ' + result.PathRank + ', Final: ' + result.Ranking + '</small></li>');
241 $('ul#results').append($('ul#results>li').detach().sort(function(a, b) {
242 return b.getAttribute('data-ranking') - a.getAttribute('data-ranking');
245 // For performance reasons, we always keep the amount of displayed
246 // results at 10. With (typically rather generic) queries where the top
247 // results are changed very often, the page would get really slow
249 var items = $('ul#results>li');
250 if (items.size() > 10) {
251 items.last().remove();
257 function loadPage(nr) {
258 // Start the progress bar after 20ms. If the page was in the cache, this
259 // timer will be cancelled by the load callback below. If it wasn’t, 20ms
260 // is short enough of a delay to not be noticed by the user.
261 var progress_bar_start = setTimeout(function() {
262 progress(0, true, 'Loading search result page ' + (nr+1) + '…');
265 var pathname = '/results/' + encodeURIComponent(searchterm) + '/page_' + nr;
266 if (location.pathname != pathname) {
267 history.pushState({ searchterm: searchterm, nr: nr, perpkg: false }, 'page ' + nr, pathname);
269 $.ajax('/results/' + queryid + '/page_' + nr + '.json')
270 .done(function(data, textStatus, xhr) {
271 clearTimeout(progress_bar_start);
272 // TODO: experiment and see whether animating the results works
273 // well. Fade them in one after the other, see:
274 // http://www.google.com/design/spec/animation/meaningful-transitions.html#meaningful-transitions-hierarchical-timing
276 updatePagination(currentpage, resultpages, false);
277 $('ul#results>li').remove();
278 var ul = $('ul#results');
279 $.each(data, function(idx, element) {
280 addSearchResult(ul, element);
282 progress(100, true, null);
284 .fail(function(xhr, textStatus, errorThrown) {
285 error(true, true, null, 'Could not load search query results: ' + errorThrown);
289 // If preload is true, the current URL will not be updated, as the data is
290 // preloaded and inserted into (hidden) DOM elements.
291 function loadPerPkgPage(nr, preload) {
292 var progress_bar_start;
294 // Start the progress bar after 20ms. If the page was in the cache,
295 // this timer will be cancelled by the load callback below. If it
296 // wasn’t, 20ms is short enough of a delay to not be noticed by the
298 progress_bar_start = setTimeout(function() {
299 progress(0, true, 'Loading search result page ' + (nr+1) + '…');
301 var pathname = '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + nr;
302 if (location.pathname != pathname) {
303 history.pushState({ searchterm: searchterm, nr: nr, perpkg: true }, 'page ' + nr, pathname);
306 $.ajax('/perpackage-results/' + queryid + '/2/page_' + nr + '.json')
307 .done(function(data, textStatus, xhr) {
308 if (progress_bar_start !== undefined) {
309 clearTimeout(progress_bar_start);
311 currentpage_pkg = nr;
312 updatePagination(currentpage_pkg, Math.trunc(packages.length / packagesPerPage), true);
313 var pp = $('#perpackage-results');
315 $.each(data, function(idx, meta) {
316 pp.append('<h2>' + meta.Package + '</h2>');
317 var ul = $('<ul></ul>');
319 $.each(meta.Results, function(idx, result) {
320 addSearchResult(ul, result);
322 var allResultsURL = '/results/' + encodeURIComponent(searchterm + ' package:' + meta.Package) + '/page_0';
323 ul.append('<li><a href="' + allResultsURL + '">show all results in package <span class="packagename">' + meta.Package + '</span></a></li>');
325 progress(100, true, null);
329 .fail(function(xhr, textStatus, errorThrown) {
330 error(true, true, null, 'Could not load search query results ("' + errorThrown + '").');
334 function pageUrl(page, perpackage) {
336 return '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + page;
338 return '/results/' + encodeURIComponent(searchterm) + '/page_' + page;
342 function updatePagination(currentpage, resultpages, perpackage) {
343 var clickFunc = (perpackage ? 'loadPerPkgPage' : 'loadPage');
344 var html = '<strong>Pages:</strong> ';
345 var start = Math.max(currentpage - 5, (currentpage > 0 ? 1 : 0));
346 var end = Math.min((currentpage >= 5 ? currentpage + 5 : 10), resultpages);
348 if (currentpage > 0) {
349 html += '<a href="' + pageUrl(currentpage-1, perpackage) + '" onclick="' + clickFunc + '(' + (currentpage-1) + ');return false;" rel="prev"><</a> ';
350 html += '<a href="' + pageUrl(0, perpackage) + '" onclick="' + clickFunc + '(0);return false;">1</a> ';
357 for (var i = start; i < end; i++) {
358 html += '<a style="' + (i == currentpage ? "font-weight: bold" : "") + '" ' +
359 'href="' + pageUrl(i, perpackage) + '" ' +
360 'onclick="' + clickFunc + '(' + i + ');return false;">' + (i + 1) + '</a> ';
363 if (end < (resultpages-1)) {
367 if (end < resultpages) {
368 html += '<a href="' + pageUrl(resultpages-1, perpackage) + '" onclick="' + clickFunc + '(' + (resultpages - 1) + ');return false;">' + resultpages + '</a>';
371 if (currentpage < (resultpages-1)) {
372 html += '<link rel="prerender" href="' + pageUrl(currentpage+1, perpackage) + '">';
373 html += '<a href="' + pageUrl(currentpage+1, perpackage) + '" onclick="' + clickFunc + '(' + (currentpage+1) + ');return false;" rel="next">></a> ';
376 $((perpackage ? '#perpackage-pagination' : '#pagination')).html(html);
379 function escapeForHTML(input) {
380 return $('<div/>').text(input).html();
383 connection.onmessage = function(e) {
384 var msg = JSON.parse(e.data);
387 queryid = msg.QueryId;
389 progress(((msg.FilesProcessed / msg.FilesTotal) * 90) + 10,
391 msg.FilesProcessed + ' / ' + msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
392 if (msg.FilesProcessed == msg.FilesTotal) {
394 if (msg.Results === 0) {
395 progress(100, false, msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
396 error(false, true, 'noresults', 'Your query “' + searchterm + '” had no results. Did you read the FAQ to make sure your syntax is correct?');
398 $('#options').show();
400 progress(100, false, msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
402 // Request the results, but grouped by Debian source package.
403 // Having these available means we can directly show them when the
404 // user decides to switch to perpackage mode.
405 loadPerPkgPage(0, true);
407 $.ajax('/results/' + queryid + '/packages.json')
408 .done(function(data, textStatus, xhr) {
409 var p = $('#packages');
411 packages = data.Packages;
412 updatePagination(currentpage_pkg, Math.trunc(packages.length / packagesPerPage), true);
413 if (data.Packages.length === 1) {
414 p.append('All results from Debian source package <strong>' + data.Packages[0] + '</strong>');
415 $('#enable-perpackage').attr('disabled', 'disabled');
416 $('label[for=enable-perpackage]').css('opacity', '0.5');
417 } else if (data.Packages.length > 1) {
418 // We are limiting the amount of packages because
419 // some browsers (e.g. Chrome 40) will stop
420 // displaying text with “white-space: nowrap” once
421 // it becomes too long.
422 var pkgLink = function(packageName) {
423 var url = '/results/' + encodeURIComponent(searchterm + ' package:' + packageName) + '/page_0';
424 return '<a href="' + url + '">' + packageName + '</a>';
426 var packagesList = data.Packages.slice(0, 1000).map(pkgLink).join(', ');
427 p.append('<span><strong>Filter by package</strong>: ' + packagesList + '</span>');
428 if ($('#packages span:first-child').prop('scrollWidth') > p.width()) {
429 p.append('<span class="showhint"><a href="#" onclick="$(\'#packageshint\').show(); return false;">▾</a></span>');
430 $('#packageshint').text('');
431 $('#packageshint').append('To see all packages which contain results: <pre>curl -s http://' + window.location.host + '/results/' + queryid + '/packages.json | jq -r \'.Packages[]\'</pre>');
434 $('#enable-perpackage').attr('disabled', null);
435 $('label[for=enable-perpackage]').css('opacity', '1.0');
437 if (window.location.pathname.lastIndexOf('/perpackage-results/', 0) === 0) {
438 var parts = new RegExp("/perpackage-results/([^/]+)/2/page_([0-9]+)").exec(window.location.pathname);
439 $('#enable-perpackage').prop('checked', true);
441 loadPerPkgPage(parseInt(parts[2]));
445 .fail(function(xhr, textStatus, errorThrown) {
446 error(true, true, null, 'Loading search result package list failed: ' + errorThrown);
453 // Store the values in global variables for constructing URLs when the
454 // user requests a different page.
455 resultpages = msg.ResultPages;
456 queryid = msg.QueryId;
459 updatePagination(currentpage, resultpages, false);
461 if (window.location.pathname.lastIndexOf('/results/', 0) === 0) {
462 var parts = new RegExp("/results/([^/]+)/page_([0-9]+)").exec(window.location.pathname);
463 loadPage(parseInt(parts[2]));
468 addSearchResult($('ul#results'), msg);
472 if (msg.ErrorType == "backendunavailable") {
473 error(false, true, msg.ErrorType, "The results may be incomplete, not all Debian Code Search servers are okay right now.");
475 error(msg.ErrorType);
480 throw new Error('Server sent unknown piece of data, type is "' + msg.Type);
484 function setPositionAbsolute(selector) {
485 var element = $(selector);
486 var pos = element.position();
487 pos.width = element.width();
488 pos.height = element.height();
489 pos.position = 'absolute';
493 function setPositionStatic(selector) {
495 'position': 'static',
502 function animationSupported() {
503 var elm = $('#perpackage')[0];
504 var prefixes = ["webkit", "MS", "moz", "o", ""];
505 for (var i = 0; i < prefixes.length; i++) {
506 if (elm.style[prefixes[i] + 'AnimationName'] !== undefined) {
513 // Switch between displaying all results and grouping search results by Debian
515 function changeGrouping() {
516 var ppelements = $('#perpackage');
518 var currentPerPkg = ppelements.is(':visible');
519 var shouldPerPkg = $('#enable-perpackage').prop('checked');
520 if (currentPerPkg === shouldPerPkg) {
524 ppelements.data('hideAfterAnimation', !shouldPerPkg);
527 $('#perpackage').addClass('animation-reverse');
529 $('#perpackage').removeClass('animation-reverse');
530 $('#perpackage').show();
534 ppelements.removeClass('animation-reverse');
535 var pathname = '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + currentpage_pkg;
536 if (location.pathname != pathname) {
538 { searchterm: searchterm, nr: currentpage_pkg, perpkg: true },
539 'page ' + currentpage_pkg,
543 setPositionAbsolute('#footer');
544 setPositionAbsolute('#normalresults');
545 $('#perpackage').show();
547 ppelements.addClass('animation-reverse');
548 var pathname = '/results/' + encodeURIComponent(searchterm) + '/page_' + currentpage;
549 if (location.pathname != pathname) {
551 { searchterm: searchterm, nr: currentpage, perpkg: false },
552 'page ' + currentpage,
555 $('#normalresults').show();
556 // For browsers that don’t support animations, we need to have a fallback.
557 // The timer will be cancelled in the animationstart event handler.
558 if (!animationSupported()) {
559 animationFallback = setTimeout(function() {
560 $('#perpackage').hide();
561 setPositionStatic('#footer, #normalresults');
566 ppelements.removeClass('ppanimation');
567 // Trigger a reflow, otherwise removing/adding the animation class does not
568 // lead to restarting the animation.
569 ppelements[0].offsetWidth = ppelements[0].offsetWidth;
570 ppelements.addClass('ppanimation');
573 // Restore autocomplete from localstorage. This is necessary because the form
574 // never gets submitted (we intercept the submit event). All the alternatives
575 // are worse and have side-effects.
576 function restoreAutocomplete() {
577 var entries = localStorage.getItem("autocomplete");
578 if (entries !== null) {
579 entries = JSON.parse(entries);
580 var dataList = document.getElementById('autocomplete');
581 $('datalist').empty();
582 $.each(entries, function() {
583 var option = document.createElement('option');
585 dataList.appendChild(option);
590 // This function needs to be called every time a scrollbar can appear (any DOM
591 // changes!) or the size of the window is changed.
593 // This is because span.progressbar-front-text needs to be the same width as
594 // div#progressbar, but there is no way to specify that in pure CSS :|.
595 function fixProgressbar() {
596 $('.progressbar-front-text').css('width', $('#progressbar').css('width'));
599 $(window).load(function() {
600 // Try to restore autocomplete settings even before the connection is
601 // established. If localStorage contains an entry, the user has used the
602 // instant search at least once, so chances are she’ll use it again.
603 restoreAutocomplete();
605 // Pressing “/” anywhere on the page focuses the search field.
606 $(document).keydown(function(e) {
607 if (e.which == 191) {
608 var q = $('#searchform input[name=q]');
609 if (q.is(':focus')) {
619 $(window).resize(fixProgressbar);
621 function bindAnimationEvent(element, name, cb) {
622 var prefixes = ["webkit", "MS", "moz", "o", ""];
623 for (var i = 0; i < prefixes.length; i++) {
625 element.bind(prefixes[i] + name.toLowerCase(), cb);
627 element.bind(prefixes[i] + name, cb);
632 var ppresults = $('#perpackage');
633 bindAnimationEvent(ppresults, 'AnimationStart', function(e) {
634 clearTimeout(animationFallback);
636 bindAnimationEvent(ppresults, 'AnimationEnd', function(e) {
637 if (ppresults.data('hideAfterAnimation')) {
639 setPositionStatic('#footer, #normalresults');
641 $('#normalresults').hide();
645 if (window.location.pathname.lastIndexOf('/results/', 0) === 0 ||
646 window.location.pathname.lastIndexOf('/perpackage-results/', 0) === 0) {
647 var parts = new RegExp("results/([^/]+)").exec(window.location.pathname);
648 $('#searchform input[name=q]').val(decodeURIComponent(parts[1]));
650 // If the websocket is not connected within 100ms, indicate progress.
651 if (connection.readyState != WebSocket.OPEN) {
652 $('#searchform input').attr('disabled', true);
653 showConnectProgress = setTimeout(function() {
654 $('#progressbar').show();
655 progress(0, true, 'Connecting…');