javascript client: add additional pagination on the bottom
[debiancodesearch.git] / static / instant.js
blob7240d8824336a9e9786e7da7727cc5c075421c5f
1 // © 2014 Michael Stapelberg
2 // vim:ts=4:sw=4:et
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;
12 var tryHTTPS = true;
13 var websocket_url = window.location.protocol.replace('http', 'ws') + '//' + window.location.host + '/instantws';
14 var connection = new ReconnectingWebSocket(websocket_url);
15 var searchterm;
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
27 // unhealthy.
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) {
32         return;
33     }
34     if (fatal) {
35         progress(100, false, 'Error: ' + message);
36         return;
37     }
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);
42     }
43     div.text(message);
44     $('#errors').append(div);
45     return 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) {
55     fixProgressbar();
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();
61     } else {
62         if (text !== null) {
63             $('#progressbar span').text(text);
64             if (!temporary) {
65                 $('#progressbar').data('old-text', text);
66             }
67         }
68         $('#progressbar .progress-bar').css('width', percentage + '%');
69         if (percentage == 100) {
70             $('#progressbar .progress-bar').removeClass('progress-active');
71             if (temporary) {
72                 $('#progressbar span').text($('#progressbar').data('old-text'));
73             }
74         }
75     }
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());
88     sf.detach();
89     sf.appendTo('#content');
90     sf.css(pos);
92     sf.animate($('#searchbox').position(), 'fast', function() {
93         $('#searchdiv').hide();
94         // Unset focus
95         $('#searchform input[name=q]').blur();
96     });
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
114         // query.
115         window.location.replace(pageUrl(0, false));
116     }
118     showResultsPage();
119     $('#packages').text('');
120     $('#errors div.alert-danger').remove();
121     var query = {
122         "Query": "q=" + encodeURIComponent(searchterm)
123     };
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]);
129     } else {
130         entries = JSON.parse(entries);
131         if (entries.indexOf(searchterm) === -1) {
132             entries.push(searchterm);
133         }
134         localStorage["autocomplete"] = JSON.stringify(entries);
135     }
136     animateSearchForm();
138     progress(0, false, 'Checking which files to grep…');
139     queryDone = false;
140     queryStarted = true;
143 connection.onopen = function() {
144     clearTimeout(showConnectProgress);
145     $('#searchform input').attr('disabled', false);
147     // The URL dictates a search query, so start it.
148     if (!queryDone &&
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]);
153         sendQuery();
154     }
156     $('#searchform').off('submit').on('submit', function(ev) {
157         searchterm = $('#searchform input[name=q]').val();
158         sendQuery();
159         history.pushState({ searchterm: searchterm, nr: 0, perpkg: false }, 'page ' + 0, '/results/' + encodeURIComponent(searchterm) + '/page_0');
160         ev.preventDefault();
161     });
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;
167         if (state == null) {
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();
174         } else {
175             if (!$('#normalresults').is(':visible') &&
176                 !$('#perpackage').is(':visible')) {
177                 showResultsPage();
178                 animateSearchForm();
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();
183             }
184             $('#enable-perpackage').prop('checked', state.perpkg);
185             changeGrouping();
186             if (state.perpkg) {
187                 loadPerPkgPage(state.nr);
188             } else {
189                 loadPage(state.nr);
190             }
191         }
192     });
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://.
205     if (tryHTTPS) {
206         connection.url = websocket_url.replace('ws://', 'wss://');
207         tryHTTPS = false;
208     }
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);
219         msg.remove();
220         connection.onopen = oldHandler;
221         oldHandler();
222     };
225 var queryid;
226 var resultpages;
227 var currentpage;
228 var currentpage_pkg;
229 var packages = [];
231 function addSearchResult(results, result) {
232     var context = [];
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]];
242         }
243     }
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');
265     }));
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
270     // otherwise.
271     var items = $('ul#results>li');
272     if (items.size() > 10) {
273         items.last().remove();
274     }
276     fixProgressbar();
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) + '…');
290     }, 200);
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);
295     }
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
302             currentpage = nr;
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);
308             });
309             progress(100, true, null);
310         })
311         .fail(function(xhr, textStatus, errorThrown) {
312             error(true, true, null, 'Could not load search query results: ' + errorThrown);
313         });
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;
320     if (!preload) {
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
329         // user.
330         progress_bar_start = setTimeout(function() {
331             progress(0, true, 'Loading per-package search result page ' + (nr+1) + '…');
332         }, 20);
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);
336         }
337     }
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);
342             }
343             currentpage_pkg = nr;
344             updatePagination(currentpage_pkg, Math.ceil(packages.length / packagesPerPage), true);
345             var pp = $('#perpackage-results');
346             pp.text('');
347             $.each(data, function(idx, meta) {
348                 pp.append('<h2>' + meta.Package + '</h2>');
349                 var ul = $('<ul></ul>');
350                 pp.append(ul);
351                 $.each(meta.Results, function(idx, result) {
352                     addSearchResult(ul, result);
353                 });
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>');
356                 if (!preload) {
357                     progress(100, true, null);
358                 }
359             });
360         })
361         .fail(function(xhr, textStatus, errorThrown) {
362             error(true, true, null, 'Could not load search query results ("' + errorThrown + '").');
363         });
366 function pageUrl(page, perpackage) {
367     if (perpackage) {
368         return '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + page;
369     } else {
370         return '/results/' + encodeURIComponent(searchterm) + '/page_' + page;
371     }
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">&lt;</a> ';
382         html += '<a href="' + pageUrl(0, perpackage) + '" onclick="' + clickFunc + '(0);return false;">1</a> ';
383     }
385     if (start > 1) {
386         html += '… ';
387     }
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> ';
393     }
395     if (end < (resultpages-1)) {
396         html += '… ';
397     }
399     if (end < resultpages) {
400         html += '<a href="' + pageUrl(resultpages-1, perpackage) + '" onclick="' + clickFunc + '(' + (resultpages - 1) + ');return false;">' + resultpages + '</a>';
401     }
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">&gt;</a> ';
406     }
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);
417     switch (msg.Type) {
418         case "progress":
419         queryid = msg.QueryId;
421         progress(((msg.FilesProcessed / msg.FilesTotal) * 90) + 10,
422                  false,
423                  msg.FilesProcessed + ' / ' + msg.FilesTotal + ' files grepped (' + msg.Results + ' results)');
424         if (msg.FilesProcessed == msg.FilesTotal) {
425             queryDone = true;
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?');
429             } else {
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');
442                         p.text('');
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>';
457                             };
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>');
464                             }
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);
472                                 changeGrouping();
473                                 loadPerPkgPage(parseInt(parts[2]));
474                             }
475                         }
476                     })
477                     .fail(function(xhr, textStatus, errorThrown) {
478                         error(true, true, null, 'Loading search result package list failed: ' + errorThrown);
479                     });
480             }
481         }
482         break;
484         case "pagination":
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;
489         currentpage = 0;
490         currentpage_pkg = 0;
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]));
496         }
497         break;
499         case "error":
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.");
508         } else {
509             error(false, true, msg.ErrorType, msg.ErrorType);
510         }
511         break;
513         default:
514         addSearchResult($('ul#results'), msg);
515         break;
516     }
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';
525     element.css(pos);
528 function setPositionStatic(selector) {
529     $(selector).css({
530         'position': 'static',
531         'left': '',
532         'top': '',
533         'width': '',
534         'height': ''});
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) {
542             return true;
543         }
544     }
545     return false;
548 // Switch between displaying all results and grouping search results by Debian
549 // source package.
550 function changeGrouping() {
551     var ppelements = $('#perpackage');
553     var currentPerPkg = ppelements.is(':visible');
554     var shouldPerPkg = $('#enable-perpackage').prop('checked');
555     if (currentPerPkg === shouldPerPkg) {
556         return;
557     }
559     ppelements.data('hideAfterAnimation', !shouldPerPkg);
561     if (currentPerPkg) {
562         $('#perpackage').addClass('animation-reverse');
563     } else {
564         $('#perpackage').removeClass('animation-reverse');
565         $('#perpackage').show();
566     }
568     if (shouldPerPkg) {
569         ppelements.removeClass('animation-reverse');
570         var pathname = '/perpackage-results/' + encodeURIComponent(searchterm) + '/2/page_' + currentpage_pkg;
571         if (location.pathname != pathname) {
572             history.pushState(
573                 { searchterm: searchterm, nr: currentpage_pkg, perpkg: true },
574                 'page ' + currentpage_pkg,
575                 pathname);
576         }
578         setPositionAbsolute('#footer');
579         setPositionAbsolute('#normalresults');
580         $('#perpackage').show();
581     } else {
582         ppelements.addClass('animation-reverse');
583         var pathname = '/results/' + encodeURIComponent(searchterm) + '/page_' + currentpage;
584         if (location.pathname != pathname) {
585             history.pushState(
586                 { searchterm: searchterm, nr: currentpage, perpkg: false },
587                 'page ' + currentpage,
588                 pathname);
589         }
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');
597             }, 100);
598         }
599     }
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');
619             option.value = this;
620             dataList.appendChild(option);
621         });
622     }
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')) {
645                 return;
646             }
647             q.focus();
648             e.preventDefault();
649         }
650     });
652     fixProgressbar();
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++) {
659             if (i >= 3) {
660                 element.bind(prefixes[i] + name.toLowerCase(), cb);
661             } else {
662                 element.bind(prefixes[i] + name, cb);
663             }
664         }
665     }
667     var ppresults = $('#perpackage');
668     bindAnimationEvent(ppresults, 'AnimationStart', function(e) {
669         clearTimeout(animationFallback);
670     });
671     bindAnimationEvent(ppresults, 'AnimationEnd',  function(e) {
672         if (ppresults.data('hideAfterAnimation')) {
673             ppresults.hide();
674             setPositionStatic('#footer, #normalresults');
675         } else {
676             $('#normalresults').hide();
677         }
678     });
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…');
691             }, 100);
692         }
693     }