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;
17 var queryStarted = false;
19 // fatal (bool): Whether all ongoing operations should be cancelled.
21 // permanent (bool): Whether this message will be displayed permanently (e.g.
22 // “search results incomplete” vs. “trying to reconnect in 3s…”)
24 // unique_id (string): If non-null, only one message of this type will be
25 // displayed. Can be used to display only one notification about incomplete
26 // search results, regardless of how many backends the server returns as
29 // message (string): The human-readable error message.
30 function error(fatal, permanent, unique_id, message) {
31 if (unique_id !== null && $('#errors div[data-uniqueid=' + unique_id + ']').size() > 0) {
35 progress(100, false, 'Error: ' + message);
39 var div = $('<div class="alert alert-' + (permanent ? 'danger' : 'warning') + '" role="alert"></div>');
40 if (unique_id !== null) {
41 div.attr('data-uniqueid', unique_id);
44 $('#errors').append(div);
48 // Setting percentage to 0 means initializing the progressbar. To display some
49 // sort of progress to the user, we’ll set it to 10%, so any actual progress
50 // that is communicated from the server will need to be ≥ 10%.
52 // Setting temporary to true will reset the text to the last non-temporary text
53 // upon completion (which is a call with percentage == 100).
54 function progress(percentage, temporary, text) {
56 if (percentage == 0) {
57 $('#progressbar span').text(text);
58 $('#progressbar .progress-bar').css('width', '10%');
59 $('#progressbar .progress-bar').addClass('progress-active');
60 $('#progressbar').show();
63 $('#progressbar span').text(text);
65 $('#progressbar').data('old-text', text);
68 $('#progressbar .progress-bar').css('width', percentage + '%');
69 if (percentage == 100) {
70 $('#progressbar .progress-bar').removeClass('progress-active');
72 $('#progressbar span').text($('#progressbar').data('old-text'));
78 // Animates the search form from the middle of the page to the top right.
79 function animateSearchForm() {
80 // A bit hackish: we rip the search form out of the DOM and use
81 // position: absolute, so that we can later animate it across the page
82 // into the top right #searchbox div.
83 var sf = $('#searchform');
84 var pos = sf.position();
85 $('#searchbox .formplaceholder').css({ width: sf.width(), height: sf.height() });
86 pos.position = 'absolute';
87 $('#searchdiv .formplaceholder').css('height', sf.height());
89 sf.appendTo('#content');
92 sf.animate($('#searchbox').position(), 'fast', function() {
93 $('#searchdiv').hide();
95 $('#searchform input[name=q]').blur();
99 function showResultsPage() {
100 $('#results li').remove();
101 $('#normalresults').show();
102 $('#progressbar').show();
103 $('#options').hide();
104 $('#packageshint').hide();
105 $('.pagination').text('');
106 $('.perpackage-pagination').text('');
109 function sendQuery() {
110 if (queryStarted && !queryDone) {
111 // We need to cancel the current query and start a new one. The best
112 // way to this (currently) is to force the browser to restart the
113 // entire client code by navigating to the results URL of the new
115 window.location.replace(pageUrl(0, false));
119 $('#packages').text('');
120 $('#errors div.alert-danger').remove();
122 "Query": "q=" + encodeURIComponent(searchterm)
124 connection.send(JSON.stringify(query));
125 document.title = searchterm + ' · Debian Code Search';
126 var entries = localStorage.getItem("autocomplete");
127 if (entries === null) {
128 localStorage["autocomplete"] = JSON.stringify([searchterm]);
130 entries = JSON.parse(entries);
131 if (entries.indexOf(searchterm) === -1) {
132 entries.push(searchterm);
134 localStorage["autocomplete"] = JSON.stringify(entries);
138 progress(0, false, 'Checking which files to grep…');
143 connection.onopen = function() {
144 clearTimeout(showConnectProgress);
145 $('#searchform input').attr('disabled', false);
147 // The URL dictates a search query, so start it.
149 (window.location.pathname.lastIndexOf('/results/', 0) === 0 ||
150 window.location.pathname.lastIndexOf('/perpackage-results/', 0) === 0)) {
151 var parts = new RegExp("results/([^/]+)").exec(window.location.pathname);
152 searchterm = decodeURIComponent(parts[1]);
156 $('#searchform').off('submit').on('submit', function(ev) {
157 searchterm = $('#searchform input[name=q]').val();
159 history.pushState({ searchterm: searchterm, nr: 0, perpkg: false }, 'page ' + 0, '/results/' + encodeURIComponent(searchterm) + '/page_0');
163 // This is triggered when the user navigates (e.g. via back button) between
164 // pages that were created using history.pushState().
165 $(window).off('popstate').on('popstate', function(ev) {
166 var state = ev.originalEvent.state;
168 // Restore the original page.
169 $('#normalresults, #perpackage, #progressbar, #errors, #packages, #options').hide();
170 $('#searchdiv').show();
171 $('#searchdiv .formplaceholder').after($('#searchform'));
172 $('#searchform').css('position', 'static');
173 restoreAutocomplete();
175 if (!$('#normalresults').is(':visible') &&
176 !$('#perpackage').is(':visible')) {
179 // The following are necessary because we don’t send the query
180 // anew and don’t get any progress messages (the final progress
181 // message triggers displaying certain elements).
182 $('#packages, #errors, #options').show();
184 $('#enable-perpackage').prop('checked', state.perpkg);
187 loadPerPkgPage(state.nr);
195 connection.onerror = function(e) {
196 // We could display an error, but since the page is supposed to fall back
197 // gracefully, why would the user be concerned if the search takes a tiny
198 // bit longer than usual?
199 // error(false, true, 'websocket_broken', 'Could not open WebSocket connection to ' + e.target.URL);
201 // Some transparent proxies don’t support WebSockets, e.g. Orange (big
202 // mobile provider in Switzerland) removes “Upgrade: ” headers from the
203 // HTTP requests. Therefore, we try to use wss:// if the connection was
204 // being made via ws://.
206 connection.url = websocket_url.replace('ws://', 'wss://');
211 connection.onclose = function(e) {
212 // XXX: ideally, we’d only display the message if the reconnect takes longer than, say, a second?
213 var msg = error(false, false, null, 'Lost connection to Debian Code Search. Reconnecting…');
214 $('#searchform input').attr('disabled', true);
216 var oldHandler = connection.onopen;
217 connection.onopen = function() {
218 $('#searchform input').attr('disabled', false);
220 connection.onopen = oldHandler;
231 function addSearchResult(results, result) {
234 // Convert old-style replies (serialized via encoding/json) to new-style
235 // replies (serialized via capnproto). Once this is rolled out we can
236 // switch the server to new-style replies.
237 // TODO: remove this code once the server is switched to new-style replies.
238 if (result.context === undefined) {
239 var members = ["Ctxp2", "Ctxp1", "Context", "Ctxn1", "Ctxn2", "Path", "Line", "PathRank", "Ranking"];
240 for (var i = 0; i < members.length; i++) {
241 result[members[i].toLowerCase()] = result[members[i]];
245 // NB: All of the following context lines are already HTML-escaped by the server.
246 context.push(result.ctxp2);
247 context.push(result.ctxp1);
248 context.push('<strong>' + result.context + '</strong>');
249 context.push(result.ctxn1);
250 context.push(result.ctxn2);
251 // Remove any empty context lines (e.g. when the match is close to the
252 // beginning or end of the file).
253 context = $.grep(context, function(elm, idx) { return $.trim(elm) != ""; });
254 context = context.join("<br>").replace("\t", " ");
256 // Split the path into source package (bold) and rest.
257 var delimiter = result.path.indexOf("_");
258 var sourcePackage = result.path.substring(0, delimiter);
259 var rest = result.path.substring(delimiter);
261 // Append the new search result, then sort the results.
262 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>');
263 $('ul#results').append($('ul#results>li').detach().sort(function(a, b) {
264 return b.getAttribute('data-ranking') - a.getAttribute('data-ranking');
267 // For performance reasons, we always keep the amount of displayed
268 // results at 10. With (typically rather generic) queries where the top
269 // results are changed very often, the page would get really slow
271 var items = $('ul#results>li');
272 if (items.size() > 10) {
273 items.last().remove();
279 function loadPage(nr) {
280 // There’s pagination at the top and at the bottom of the page. In case the
281 // user used the bottom one, it makes sense to scroll back to the top. In
282 // case the user used the top one, the scrolling won’t be noticed.
283 window.scrollTo(0, 0);
285 // Start the progress bar after 200ms. If the page was in the cache, this
286 // timer will be cancelled by the load callback below. If it wasn’t, 200ms
287 // is short enough of a delay to not be noticed by the user.
288 var progress_bar_start = setTimeout(function() {
289 progress(0, true, 'Loading search result page ' + (nr+1) + '…');
292 var pathname = '/results/' + encodeURIComponent(searchterm) + '/page_' + nr;
293 if (location.pathname != pathname) {
294 history.pushState({ searchterm: searchterm, nr: nr, perpkg: false }, 'page ' + nr, pathname);
296 $.ajax('/results/' + queryid + '/page_' + nr + '.json')
297 .done(function(data, textStatus, xhr) {
298 clearTimeout(progress_bar_start);
299 // TODO: experiment and see whether animating the results works
300 // well. Fade them in one after the other, see:
301 // http://www.google.com/design/spec/animation/meaningful-transitions.html#meaningful-transitions-hierarchical-timing
303 updatePagination(currentpage, resultpages, false);
304 $('ul#results>li').remove();
305 var ul = $('ul#results');
306 $.each(data, function(idx, element) {
307 addSearchResult(ul, element);
309 progress(100, true, null);
311 .fail(function(xhr, textStatus, errorThrown) {
312 error(true, true, null, 'Could not load search query results: ' + errorThrown);
316 // If preload is true, the current URL will not be updated, as the data is
317 // preloaded and inserted into (hidden) DOM elements.
318 function loadPerPkgPage(nr, preload) {
319 var progress_bar_start;
321 // There’s pagination at the top and at the bottom of the page. In case the
322 // user used the bottom one, it makes sense to scroll back to the top. In
323 // case the user used the top one, the scrolling won’t be noticed.
324 window.scrollTo(0, 0);
326 // Start the progress bar after 20ms. If the page was in the cache,
327 // this timer will be cancelled by the load callback below. If it
328 // wasn’t, 20ms is short enough of a delay to not be noticed by the
330 progress_bar_start = setTimeout(function() {
331 progress(0, true, 'Loading per-package search result page ' + (nr+1) + '…');
333 var pathname = '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + nr;
334 if (location.pathname != pathname) {
335 history.pushState({ searchterm: searchterm, nr: nr, perpkg: true }, 'page ' + nr, pathname);
338 $.ajax('/results/' + queryid + '/perpackage_2_page_' + nr + '.json')
339 .done(function(data, textStatus, xhr) {
340 if (progress_bar_start !== undefined) {
341 clearTimeout(progress_bar_start);
343 currentpage_pkg = nr;
344 updatePagination(currentpage_pkg, Math.ceil(packages.length / packagesPerPage), true);
345 var pp = $('#perpackage-results');
347 $.each(data, function(idx, meta) {
348 pp.append('<h2>' + meta.Package + '</h2>');
349 var ul = $('<ul></ul>');
351 $.each(meta.Results, function(idx, result) {
352 addSearchResult(ul, result);
354 var allResultsURL = '/results/' + encodeURIComponent(searchterm + ' package:' + meta.Package) + '/page_0';
355 ul.append('<li><a href="' + allResultsURL + '">show all results in package <span class="packagename">' + meta.Package + '</span></a></li>');
357 progress(100, true, null);
361 .fail(function(xhr, textStatus, errorThrown) {
362 error(true, true, null, 'Could not load search query results ("' + errorThrown + '").');
366 function pageUrl(page, perpackage) {
368 return '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + page;
370 return '/results/' + encodeURIComponent(searchterm) + '/page_' + page;
374 function updatePagination(currentpage, resultpages, perpackage) {
375 var clickFunc = (perpackage ? 'loadPerPkgPage' : 'loadPage');
376 var html = '<strong>Pages:</strong> ';
377 var start = Math.max(currentpage - 5, (currentpage > 0 ? 1 : 0));
378 var end = Math.min((currentpage >= 5 ? currentpage + 5 : 10), resultpages);
380 if (currentpage > 0) {
381 html += '<a href="' + pageUrl(currentpage-1, perpackage) + '" onclick="' + clickFunc + '(' + (currentpage-1) + ');return false;" rel="prev"><</a> ';
382 html += '<a href="' + pageUrl(0, perpackage) + '" onclick="' + clickFunc + '(0);return false;">1</a> ';
389 for (var i = start; i < end; i++) {
390 html += '<a style="' + (i == currentpage ? "font-weight: bold" : "") + '" ' +
391 'href="' + pageUrl(i, perpackage) + '" ' +
392 'onclick="' + clickFunc + '(' + i + ');return false;">' + (i + 1) + '</a> ';
395 if (end < (resultpages-1)) {
399 if (end < resultpages) {
400 html += '<a href="' + pageUrl(resultpages-1, perpackage) + '" onclick="' + clickFunc + '(' + (resultpages - 1) + ');return false;">' + resultpages + '</a>';
403 if (currentpage < (resultpages-1)) {
404 html += '<link rel="prerender" href="' + pageUrl(currentpage+1, perpackage) + '">';
405 html += '<a href="' + pageUrl(currentpage+1, perpackage) + '" onclick="' + clickFunc + '(' + (currentpage+1) + ');return false;" rel="next">></a> ';
408 $((perpackage ? '.perpackage-pagination' : '.pagination')).html(html);
411 function escapeForHTML(input) {
412 return $('<div/>').text(input).html();
415 connection.onmessage = function(e) {
416 var msg = JSON.parse(e.data);
419 queryid = msg.QueryId;
421 progress(((msg.FilesProcessed / msg.FilesTotal) * 90) + 10,
423 msg.FilesProcessed + ' / ' + msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
424 if (msg.FilesProcessed == msg.FilesTotal) {
426 if (msg.Results === 0) {
427 progress(100, false, msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
428 error(false, true, 'noresults', 'Your query “' + searchterm + '” had no results. Did you read the FAQ to make sure your syntax is correct?');
430 $('#options').show();
432 progress(100, false, msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
434 // Request the results, but grouped by Debian source package.
435 // Having these available means we can directly show them when the
436 // user decides to switch to perpackage mode.
437 loadPerPkgPage(0, true);
439 $.ajax('/results/' + queryid + '/packages.json')
440 .done(function(data, textStatus, xhr) {
441 var p = $('#packages');
443 packages = data.Packages;
444 updatePagination(currentpage_pkg, Math.ceil(packages.length / packagesPerPage), true);
445 if (data.Packages.length === 1) {
446 p.append('All results from Debian source package <strong>' + data.Packages[0] + '</strong>');
447 $('#enable-perpackage').attr('disabled', 'disabled');
448 $('label[for=enable-perpackage]').css('opacity', '0.5');
449 } else if (data.Packages.length > 1) {
450 // We are limiting the amount of packages because
451 // some browsers (e.g. Chrome 40) will stop
452 // displaying text with “white-space: nowrap” once
453 // it becomes too long.
454 var pkgLink = function(packageName) {
455 var url = '/results/' + encodeURIComponent(searchterm + ' package:' + packageName) + '/page_0';
456 return '<a href="' + url + '">' + packageName + '</a>';
458 var packagesList = data.Packages.slice(0, 1000).map(pkgLink).join(', ');
459 p.append('<span><strong>Filter by package</strong>: ' + packagesList + '</span>');
460 if ($('#packages span:first-child').prop('scrollWidth') > p.width()) {
461 p.append('<span class="showhint"><a href="#" onclick="$(\'#packageshint\').show(); return false;">▾</a></span>');
462 $('#packageshint').text('');
463 $('#packageshint').append('To see all packages which contain results: <pre>curl -s http://' + window.location.host + '/results/' + queryid + '/packages.json | jq -r \'.Packages[]\'</pre>');
466 $('#enable-perpackage').attr('disabled', null);
467 $('label[for=enable-perpackage]').css('opacity', '1.0');
469 if (window.location.pathname.lastIndexOf('/perpackage-results/', 0) === 0) {
470 var parts = new RegExp("/perpackage-results/([^/]+)/2/page_([0-9]+)").exec(window.location.pathname);
471 $('#enable-perpackage').prop('checked', true);
473 loadPerPkgPage(parseInt(parts[2]));
477 .fail(function(xhr, textStatus, errorThrown) {
478 error(true, true, null, 'Loading search result package list failed: ' + errorThrown);
485 // Store the values in global variables for constructing URLs when the
486 // user requests a different page.
487 resultpages = msg.ResultPages;
488 queryid = msg.QueryId;
491 updatePagination(currentpage, resultpages, false);
493 if (window.location.pathname.lastIndexOf('/results/', 0) === 0) {
494 var parts = new RegExp("/results/([^/]+)/page_([0-9]+)").exec(window.location.pathname);
495 loadPage(parseInt(parts[2]));
500 if (msg.ErrorType == "backendunavailable") {
501 error(false, true, msg.ErrorType, "The results may be incomplete, not all Debian Code Search servers are okay right now.");
502 } else if (msg.ErrorType == "cancelled") {
503 error(false, true, msg.ErrorType, "This query has been cancelled by the server administrator (to preserve overall service health).");
504 } else if (msg.ErrorType == "failed") {
505 error(false, true, msg.ErrorType, "This query failed due to an unexpected internal server error.");
506 } else if (msg.ErrorType == "invalidquery") {
507 error(false, true, msg.ErrorType, "This query was refused by the server, because it is too short or malformed.");
509 error(false, true, msg.ErrorType, msg.ErrorType);
514 addSearchResult($('ul#results'), msg);
519 function setPositionAbsolute(selector) {
520 var element = $(selector);
521 var pos = element.position();
522 pos.width = element.width();
523 pos.height = element.height();
524 pos.position = 'absolute';
528 function setPositionStatic(selector) {
530 'position': 'static',
537 function animationSupported() {
538 var elm = $('#perpackage')[0];
539 var prefixes = ["webkit", "MS", "moz", "o", ""];
540 for (var i = 0; i < prefixes.length; i++) {
541 if (elm.style[prefixes[i] + 'AnimationName'] !== undefined) {
548 // Switch between displaying all results and grouping search results by Debian
550 function changeGrouping() {
551 var ppelements = $('#perpackage');
553 var currentPerPkg = ppelements.is(':visible');
554 var shouldPerPkg = $('#enable-perpackage').prop('checked');
555 if (currentPerPkg === shouldPerPkg) {
559 ppelements.data('hideAfterAnimation', !shouldPerPkg);
562 $('#perpackage').addClass('animation-reverse');
564 $('#perpackage').removeClass('animation-reverse');
565 $('#perpackage').show();
569 ppelements.removeClass('animation-reverse');
570 var pathname = '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + currentpage_pkg;
571 if (location.pathname != pathname) {
573 { searchterm: searchterm, nr: currentpage_pkg, perpkg: true },
574 'page ' + currentpage_pkg,
578 setPositionAbsolute('#footer');
579 setPositionAbsolute('#normalresults');
580 $('#perpackage').show();
582 ppelements.addClass('animation-reverse');
583 var pathname = '/results/' + encodeURIComponent(searchterm) + '/page_' + currentpage;
584 if (location.pathname != pathname) {
586 { searchterm: searchterm, nr: currentpage, perpkg: false },
587 'page ' + currentpage,
590 $('#normalresults').show();
591 // For browsers that don’t support animations, we need to have a fallback.
592 // The timer will be cancelled in the animationstart event handler.
593 if (!animationSupported()) {
594 animationFallback = setTimeout(function() {
595 $('#perpackage').hide();
596 setPositionStatic('#footer, #normalresults');
601 ppelements.removeClass('ppanimation');
602 // Trigger a reflow, otherwise removing/adding the animation class does not
603 // lead to restarting the animation.
604 ppelements[0].offsetWidth = ppelements[0].offsetWidth;
605 ppelements.addClass('ppanimation');
608 // Restore autocomplete from localstorage. This is necessary because the form
609 // never gets submitted (we intercept the submit event). All the alternatives
610 // are worse and have side-effects.
611 function restoreAutocomplete() {
612 var entries = localStorage.getItem("autocomplete");
613 if (entries !== null) {
614 entries = JSON.parse(entries);
615 var dataList = document.getElementById('autocomplete');
616 $('datalist').empty();
617 $.each(entries, function() {
618 var option = document.createElement('option');
620 dataList.appendChild(option);
625 // This function needs to be called every time a scrollbar can appear (any DOM
626 // changes!) or the size of the window is changed.
628 // This is because span.progressbar-front-text needs to be the same width as
629 // div#progressbar, but there is no way to specify that in pure CSS :|.
630 function fixProgressbar() {
631 $('.progressbar-front-text').css('width', $('#progressbar').css('width'));
634 $(window).load(function() {
635 // Try to restore autocomplete settings even before the connection is
636 // established. If localStorage contains an entry, the user has used the
637 // instant search at least once, so chances are she’ll use it again.
638 restoreAutocomplete();
640 // Pressing “/” anywhere on the page focuses the search field.
641 $(document).keydown(function(e) {
642 if (e.which == 191) {
643 var q = $('#searchform input[name=q]');
644 if (q.is(':focus')) {
654 $(window).resize(fixProgressbar);
656 function bindAnimationEvent(element, name, cb) {
657 var prefixes = ["webkit", "MS", "moz", "o", ""];
658 for (var i = 0; i < prefixes.length; i++) {
660 element.bind(prefixes[i] + name.toLowerCase(), cb);
662 element.bind(prefixes[i] + name, cb);
667 var ppresults = $('#perpackage');
668 bindAnimationEvent(ppresults, 'AnimationStart', function(e) {
669 clearTimeout(animationFallback);
671 bindAnimationEvent(ppresults, 'AnimationEnd', function(e) {
672 if (ppresults.data('hideAfterAnimation')) {
674 setPositionStatic('#footer, #normalresults');
676 $('#normalresults').hide();
680 if (window.location.pathname.lastIndexOf('/results/', 0) === 0 ||
681 window.location.pathname.lastIndexOf('/perpackage-results/', 0) === 0) {
682 var parts = new RegExp("results/([^/]+)").exec(window.location.pathname);
683 $('#searchform input[name=q]').val(decodeURIComponent(parts[1]));
685 // If the websocket is not connected within 100ms, indicate progress.
686 if (connection.readyState != WebSocket.OPEN) {
687 $('#searchform input').attr('disabled', true);
688 showConnectProgress = setTimeout(function() {
689 $('#progressbar').show();
690 progress(0, true, 'Connecting…');