Upstream version 5.34.92.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / inspect / inspect.js
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 var MIN_VERSION_TAB_CLOSE = 25;
6 var MIN_VERSION_TARGET_ID = 26;
7 var MIN_VERSION_NEW_TAB = 29;
8 var MIN_VERSION_TAB_ACTIVATE = 30;
9
10 function sendCommand(command, args) {
11   chrome.send(command, Array.prototype.slice.call(arguments, 1));
12 }
13
14 function sendTargetCommand(command, target) {
15   sendCommand(command, target.source, target.id);
16 }
17
18 function removeChildren(element_id) {
19   var element = $(element_id);
20   element.textContent = '';
21 }
22
23 function onload() {
24   var tabContents = document.querySelectorAll('#content > div');
25   for (var i = 0; i != tabContents.length; i++) {
26     var tabContent = tabContents[i];
27     var tabName = tabContent.querySelector('.content-header').textContent;
28
29     var tabHeader = document.createElement('div');
30     tabHeader.className = 'tab-header';
31     var button = document.createElement('button');
32     button.textContent = tabName;
33     tabHeader.appendChild(button);
34     tabHeader.addEventListener('click', selectTab.bind(null, tabContent.id));
35     $('navigation').appendChild(tabHeader);
36   }
37   onHashChange();
38   initSettings();
39   sendCommand('init-ui');
40 }
41
42 function onHashChange() {
43   var hash = window.location.hash.slice(1).toLowerCase();
44   if (!selectTab(hash))
45     selectTab('devices');
46 }
47
48 /**
49  * @param {string} id Tab id.
50  * @return {boolean} True if successful.
51  */
52 function selectTab(id) {
53   var tabContents = document.querySelectorAll('#content > div');
54   var tabHeaders = $('navigation').querySelectorAll('.tab-header');
55   var found = false;
56   for (var i = 0; i != tabContents.length; i++) {
57     var tabContent = tabContents[i];
58     var tabHeader = tabHeaders[i];
59     if (tabContent.id == id) {
60       tabContent.classList.add('selected');
61       tabHeader.classList.add('selected');
62       found = true;
63     } else {
64       tabContent.classList.remove('selected');
65       tabHeader.classList.remove('selected');
66     }
67   }
68   if (!found)
69     return false;
70   window.location.hash = id;
71   return true;
72 }
73
74 function populateTargets(source, data) {
75   if (source == 'renderers')
76     populateWebContentsTargets(data);
77   else if (source == 'workers')
78     populateWorkerTargets(data);
79   else if (source == 'adb')
80     populateRemoteTargets(data);
81   else
82     console.error('Unknown source type: ' + source);
83 }
84
85 function populateWebContentsTargets(data) {
86   removeChildren('pages-list');
87   removeChildren('extensions-list');
88   removeChildren('apps-list');
89   removeChildren('others-list');
90
91   for (var i = 0; i < data.length; i++) {
92     if (data[i].type === 'page')
93       addToPagesList(data[i]);
94     else if (data[i].type === 'background_page')
95       addToExtensionsList(data[i]);
96     else if (data[i].type === 'app')
97       addToAppsList(data[i]);
98     else
99       addToOthersList(data[i]);
100   }
101 }
102
103 function populateWorkerTargets(data) {
104   removeChildren('workers-list');
105
106   for (var i = 0; i < data.length; i++)
107     addToWorkersList(data[i]);
108 }
109
110 function populateRemoteTargets(devices) {
111   if (!devices)
112     return;
113
114   if (window.modal) {
115     window.holdDevices = devices;
116     return;
117   }
118
119   function alreadyDisplayed(element, data) {
120     var json = JSON.stringify(data);
121     if (element.cachedJSON == json)
122       return true;
123     element.cachedJSON = json;
124     return false;
125   }
126
127   function insertChildSortedById(parent, child) {
128     for (var sibling = parent.firstElementChild;
129                      sibling;
130                      sibling = sibling.nextElementSibling) {
131       if (sibling.id > child.id) {
132         parent.insertBefore(child, sibling);
133         return;
134       }
135     }
136     parent.appendChild(child);
137   }
138
139   var deviceList = $('devices-list');
140   if (alreadyDisplayed(deviceList, devices))
141     return;
142
143   function removeObsolete(validIds, section) {
144     if (validIds.indexOf(section.id) < 0)
145       section.remove();
146   }
147
148   var newDeviceIds = devices.map(function(d) { return d.id });
149   Array.prototype.forEach.call(
150       deviceList.querySelectorAll('.device'),
151       removeObsolete.bind(null, newDeviceIds));
152
153   $('devices-help').hidden = !!devices.length;
154
155   for (var d = 0; d < devices.length; d++) {
156     var device = devices[d];
157
158     var deviceSection = $(device.id);
159     if (!deviceSection) {
160       deviceSection = document.createElement('div');
161       deviceSection.id = device.id;
162       deviceSection.className = 'device';
163       deviceList.appendChild(deviceSection);
164
165       var deviceHeader = document.createElement('div');
166       deviceHeader.className = 'device-header';
167       deviceSection.appendChild(deviceHeader);
168
169       var deviceName = document.createElement('div');
170       deviceName.className = 'device-name';
171       deviceHeader.appendChild(deviceName);
172
173       var deviceSerial = document.createElement('div');
174       deviceSerial.className = 'device-serial';
175       deviceSerial.textContent = '#' + device.adbSerial.toUpperCase();
176       deviceHeader.appendChild(deviceSerial);
177
178       var devicePorts = document.createElement('div');
179       devicePorts.className = 'device-ports';
180       deviceHeader.appendChild(devicePorts);
181
182       var browserList = document.createElement('div');
183       browserList.className = 'browsers';
184       deviceSection.appendChild(browserList);
185
186       var authenticating = document.createElement('div');
187       authenticating.className = 'device-auth';
188       deviceSection.appendChild(authenticating);
189     }
190
191     if (alreadyDisplayed(deviceSection, device))
192       continue;
193
194     deviceSection.querySelector('.device-name').textContent = device.adbModel;
195     deviceSection.querySelector('.device-auth').textContent =
196         device.adbConnected ? '' : 'Pending authentication: please accept ' +
197           'debugging session on the device.';
198
199     var devicePorts = deviceSection.querySelector('.device-ports');
200     devicePorts.textContent = '';
201     if (device.adbPortStatus) {
202       for (var port in device.adbPortStatus) {
203         var status = device.adbPortStatus[port];
204         var portIcon = document.createElement('div');
205         portIcon.className = 'port-icon';
206         if (status > 0)
207           portIcon.classList.add('connected');
208         else if (status == -1 || status == -2)
209           portIcon.classList.add('transient');
210         else if (status < 0)
211           portIcon.classList.add('error');
212         devicePorts.appendChild(portIcon);
213
214         var portNumber = document.createElement('div');
215         portNumber.className = 'port-number';
216         portNumber.textContent = ':' + port;
217         if (status > 0)
218           portNumber.textContent += '(' + status + ')';
219         devicePorts.appendChild(portNumber);
220       }
221     }
222
223     var browserList = deviceSection.querySelector('.browsers');
224     var newBrowserIds =
225         device.browsers.map(function(b) { return b.id });
226     Array.prototype.forEach.call(
227         browserList.querySelectorAll('.browser'),
228         removeObsolete.bind(null, newBrowserIds));
229
230     for (var b = 0; b < device.browsers.length; b++) {
231       var browser = device.browsers[b];
232
233       var majorChromeVersion = browser.adbBrowserChromeVersion;
234
235       var pageList;
236       var browserSection = $(browser.id);
237       if (browserSection) {
238         pageList = browserSection.querySelector('.pages');
239       } else {
240         browserSection = document.createElement('div');
241         browserSection.id = browser.id;
242         browserSection.className = 'browser';
243         insertChildSortedById(browserList, browserSection);
244
245         var browserHeader = document.createElement('div');
246         browserHeader.className = 'browser-header';
247
248         var browserName = document.createElement('div');
249         browserName.className = 'browser-name';
250         browserHeader.appendChild(browserName);
251         browserName.textContent = browser.adbBrowserName;
252         if (browser.adbBrowserVersion)
253           browserName.textContent += ' (' + browser.adbBrowserVersion + ')';
254         browserSection.appendChild(browserHeader);
255
256         if (majorChromeVersion >= MIN_VERSION_NEW_TAB) {
257           var newPage = document.createElement('div');
258           newPage.className = 'open';
259
260           var newPageUrl = document.createElement('input');
261           newPageUrl.type = 'text';
262           newPageUrl.placeholder = 'Open tab with url';
263           newPage.appendChild(newPageUrl);
264
265           var openHandler = function(sourceId, browserId, input) {
266             sendCommand(
267                 'open', sourceId, browserId, input.value || 'about:blank');
268             input.value = '';
269           }.bind(null, browser.source, browser.id, newPageUrl);
270           newPageUrl.addEventListener('keyup', function(handler, event) {
271             if (event.keyIdentifier == 'Enter' && event.target.value)
272               handler();
273           }.bind(null, openHandler), true);
274
275           var newPageButton = document.createElement('button');
276           newPageButton.textContent = 'Open';
277           newPage.appendChild(newPageButton);
278           newPageButton.addEventListener('click', openHandler, true);
279
280           browserHeader.appendChild(newPage);
281         }
282
283         pageList = document.createElement('div');
284         pageList.className = 'list pages';
285         browserSection.appendChild(pageList);
286       }
287
288       if (alreadyDisplayed(browserSection, browser))
289         continue;
290
291       pageList.textContent = '';
292       for (var p = 0; p < browser.pages.length; p++) {
293         var page = browser.pages[p];
294         // Attached targets have no unique id until Chrome 26. For such targets
295         // it is impossible to activate existing DevTools window.
296         page.hasNoUniqueId = page.attached &&
297             (majorChromeVersion && majorChromeVersion < MIN_VERSION_TARGET_ID);
298         var row = addTargetToList(page, pageList, ['name', 'url']);
299         if (page['description'])
300           addWebViewDetails(row, page);
301         else
302           addFavicon(row, page);
303         if (majorChromeVersion >= MIN_VERSION_TAB_ACTIVATE) {
304           addActionLink(row, 'focus tab',
305               sendTargetCommand.bind(null, 'activate', page), false);
306         }
307         if (majorChromeVersion) {
308           addActionLink(row, 'reload',
309               sendTargetCommand.bind(null, 'reload', page), page.attached);
310         }
311         if (majorChromeVersion >= MIN_VERSION_TAB_CLOSE) {
312           addActionLink(row, 'close',
313               sendTargetCommand.bind(null, 'close', page), page.attached);
314         }
315       }
316     }
317   }
318 }
319
320 function addToPagesList(data) {
321   var row = addTargetToList(data, $('pages-list'), ['name', 'url']);
322   addFavicon(row, data);
323   if (data.guests)
324     addGuestViews(row, data.guests);
325 }
326
327 function addToExtensionsList(data) {
328   var row = addTargetToList(data, $('extensions-list'), ['name', 'url']);
329   addFavicon(row, data);
330   if (data.guests)
331     addGuestViews(row, data.guests);
332 }
333
334 function addToAppsList(data) {
335   var row = addTargetToList(data, $('apps-list'), ['name', 'url']);
336   addFavicon(row, data);
337   if (data.guests)
338     addGuestViews(row, data.guests);
339 }
340
341 function addGuestViews(row, guests) {
342   Array.prototype.forEach.call(guests, function(guest) {
343     var guestRow = addTargetToList(guest, row, ['name', 'url']);
344     guestRow.classList.add('guest');
345     addFavicon(guestRow, guest);
346   });
347 }
348
349 function addToWorkersList(data) {
350   var row =
351       addTargetToList(data, $('workers-list'), ['name', 'description', 'url']);
352   addActionLink(row, 'terminate',
353       sendTargetCommand.bind(null, 'close', data), data.attached);
354 }
355
356 function addToOthersList(data) {
357   addTargetToList(data, $('others-list'), ['url']);
358 }
359
360 function formatValue(data, property) {
361   var value = data[property];
362
363   if (property == 'name' && value == '') {
364     value = 'untitled';
365   }
366
367   var text = value ? String(value) : '';
368   if (text.length > 100)
369     text = text.substring(0, 100) + '\u2026';
370
371   var span = document.createElement('div');
372   span.textContent = text;
373   span.className = property;
374   return span;
375 }
376
377 function addFavicon(row, data) {
378   var favicon = document.createElement('img');
379   if (data['faviconUrl'])
380     favicon.src = data['faviconUrl'];
381   row.insertBefore(favicon, row.firstChild);
382 }
383
384 function addWebViewDetails(row, data) {
385   var webview;
386   try {
387     webview = JSON.parse(data['description']);
388   } catch (e) {
389     return;
390   }
391   addWebViewDescription(row, webview);
392   if (data.adbScreenWidth && data.adbScreenHeight)
393     addWebViewThumbnail(
394         row, webview, data.adbScreenWidth, data.adbScreenHeight);
395 }
396
397 function addWebViewDescription(row, webview) {
398   var viewStatus = { visibility: '', position: '', size: '' };
399   if (!webview.empty) {
400     if (webview.attached && !webview.visible)
401       viewStatus.visibility = 'hidden';
402     else if (!webview.attached)
403       viewStatus.visibility = 'detached';
404     viewStatus.size = 'size ' + webview.width + ' \u00d7 ' + webview.height;
405   } else {
406     viewStatus.visibility = 'empty';
407   }
408   if (webview.attached) {
409       viewStatus.position =
410         'at (' + webview.screenX + ', ' + webview.screenY + ')';
411   }
412
413   var subRow = document.createElement('div');
414   subRow.className = 'subrow webview';
415   if (webview.empty || !webview.attached || !webview.visible)
416     subRow.className += ' invisible-view';
417   if (viewStatus.visibility)
418     subRow.appendChild(formatValue(viewStatus, 'visibility'));
419   subRow.appendChild(formatValue(viewStatus, 'position'));
420   subRow.appendChild(formatValue(viewStatus, 'size'));
421   var mainSubrow = row.querySelector('.subrow.main');
422   if (mainSubrow.nextSibling)
423     mainSubrow.parentNode.insertBefore(subRow, mainSubrow.nextSibling);
424   else
425     mainSubrow.parentNode.appendChild(subRow);
426 }
427
428 function addWebViewThumbnail(row, webview, screenWidth, screenHeight) {
429   var maxScreenRectSize = 50;
430   var screenRectWidth;
431   var screenRectHeight;
432
433   var aspectRatio = screenWidth / screenHeight;
434   if (aspectRatio < 1) {
435     screenRectWidth = Math.round(maxScreenRectSize * aspectRatio);
436     screenRectHeight = maxScreenRectSize;
437   } else {
438     screenRectWidth = maxScreenRectSize;
439     screenRectHeight = Math.round(maxScreenRectSize / aspectRatio);
440   }
441
442   var thumbnail = document.createElement('div');
443   thumbnail.className = 'webview-thumbnail';
444   var thumbnailWidth = 3 * screenRectWidth;
445   var thumbnailHeight = 60;
446   thumbnail.style.width = thumbnailWidth + 'px';
447   thumbnail.style.height = thumbnailHeight + 'px';
448
449   var screenRect = document.createElement('div');
450   screenRect.className = 'screen-rect';
451   screenRect.style.left = screenRectWidth + 'px';
452   screenRect.style.top = (thumbnailHeight - screenRectHeight) / 2 + 'px';
453   screenRect.style.width = screenRectWidth + 'px';
454   screenRect.style.height = screenRectHeight + 'px';
455   thumbnail.appendChild(screenRect);
456
457   if (!webview.empty && webview.attached) {
458     var viewRect = document.createElement('div');
459     viewRect.className = 'view-rect';
460     if (!webview.visible)
461       viewRect.classList.add('hidden');
462     function percent(ratio) {
463       return ratio * 100 + '%';
464     }
465     viewRect.style.left = percent(webview.screenX / screenWidth);
466     viewRect.style.top = percent(webview.screenY / screenHeight);
467     viewRect.style.width = percent(webview.width / screenWidth);
468     viewRect.style.height = percent(webview.height / screenHeight);
469     screenRect.appendChild(viewRect);
470   }
471
472   row.insertBefore(thumbnail, row.firstChild);
473 }
474
475 function addTargetToList(data, list, properties) {
476   var row = document.createElement('div');
477   row.className = 'row';
478
479   var subrowBox = document.createElement('div');
480   subrowBox.className = 'subrow-box';
481   row.appendChild(subrowBox);
482
483   var subrow = document.createElement('div');
484   subrow.className = 'subrow main';
485   subrowBox.appendChild(subrow);
486
487   var description = null;
488   for (var j = 0; j < properties.length; j++)
489     subrow.appendChild(formatValue(data, properties[j]));
490
491   if (description)
492     addWebViewDescription(description, subrowBox);
493
494   var actionBox = document.createElement('div');
495   actionBox.className = 'actions';
496   subrowBox.appendChild(actionBox);
497
498   addActionLink(row, 'inspect', sendTargetCommand.bind(null, 'inspect', data),
499       data.hasNoUniqueId || data.adbAttachedForeign);
500
501   list.appendChild(row);
502   return row;
503 }
504
505 function addActionLink(row, text, handler, opt_disabled) {
506   var link = document.createElement('span');
507   link.classList.add('action');
508   if (opt_disabled)
509     link.classList.add('disabled');
510   else
511     link.classList.remove('disabled');
512
513   link.textContent = text;
514   link.addEventListener('click', handler, true);
515   row.querySelector('.actions').appendChild(link);
516 }
517
518
519 function initSettings() {
520   $('discover-usb-devices-enable').addEventListener('change',
521                                                     enableDiscoverUsbDevices);
522
523   $('port-forwarding-enable').addEventListener('change', enablePortForwarding);
524   $('port-forwarding-config-open').addEventListener(
525       'click', openPortForwardingConfig);
526   $('port-forwarding-config-close').addEventListener(
527       'click', closePortForwardingConfig);
528   $('port-forwarding-config-done').addEventListener(
529       'click', commitPortForwardingConfig.bind(true));
530 }
531
532 function enableDiscoverUsbDevices(event) {
533   sendCommand('set-discover-usb-devices-enabled', event.target.checked);
534 }
535
536 function enablePortForwarding(event) {
537   sendCommand('set-port-forwarding-enabled', event.target.checked);
538 }
539
540 function handleKey(event) {
541   switch (event.keyCode) {
542     case 13:  // Enter
543       if (event.target.nodeName == 'INPUT') {
544         var line = event.target.parentNode;
545         if (!line.classList.contains('fresh') ||
546             line.classList.contains('empty')) {
547           commitPortForwardingConfig(true);
548         } else {
549           commitFreshLineIfValid(true /* select new line */);
550           commitPortForwardingConfig(false);
551         }
552       } else {
553         commitPortForwardingConfig(true);
554       }
555       break;
556
557     case 27:
558       commitPortForwardingConfig(true);
559       break;
560   }
561 }
562
563 function setModal(dialog) {
564   dialog.deactivatedNodes = Array.prototype.filter.call(
565       document.querySelectorAll('*'),
566       function(n) {
567         return n != dialog && !dialog.contains(n) && n.tabIndex >= 0;
568       });
569
570   dialog.tabIndexes = dialog.deactivatedNodes.map(
571     function(n) { return n.getAttribute('tabindex'); });
572
573   dialog.deactivatedNodes.forEach(function(n) { n.tabIndex = -1; });
574   window.modal = dialog;
575 }
576
577 function unsetModal(dialog) {
578   for (var i = 0; i < dialog.deactivatedNodes.length; i++) {
579     var node = dialog.deactivatedNodes[i];
580     if (dialog.tabIndexes[i] === null)
581       node.removeAttribute('tabindex');
582     else
583       node.setAttribute('tabindex', tabIndexes[i]);
584   }
585
586   if (window.holdDevices) {
587     populateRemoteTargets(window.holdDevices);
588     delete window.holdDevices;
589   }
590
591   delete dialog.deactivatedNodes;
592   delete dialog.tabIndexes;
593   delete window.modal;
594 }
595
596 function openPortForwardingConfig() {
597   loadPortForwardingConfig(window.portForwardingConfig);
598
599   $('port-forwarding-overlay').classList.add('open');
600   document.addEventListener('keyup', handleKey);
601
602   var freshPort = document.querySelector('.fresh .port');
603   if (freshPort)
604     freshPort.focus();
605   else
606     $('port-forwarding-config-done').focus();
607
608   setModal($('port-forwarding-overlay'));
609 }
610
611 function closePortForwardingConfig() {
612   $('port-forwarding-overlay').classList.remove('open');
613   document.removeEventListener('keyup', handleKey);
614   unsetModal($('port-forwarding-overlay'));
615 }
616
617 function loadPortForwardingConfig(config) {
618   var list = $('port-forwarding-config-list');
619   list.textContent = '';
620   for (var port in config)
621     list.appendChild(createConfigLine(port, config[port]));
622   list.appendChild(createEmptyConfigLine());
623 }
624
625 function commitPortForwardingConfig(closeConfig) {
626   if (closeConfig)
627     closePortForwardingConfig();
628
629   commitFreshLineIfValid();
630   var lines = document.querySelectorAll('.port-forwarding-pair');
631   var config = {};
632   for (var i = 0; i != lines.length; i++) {
633     var line = lines[i];
634     var portInput = line.querySelector('.port');
635     var locationInput = line.querySelector('.location');
636
637     var port = portInput.classList.contains('invalid') ?
638                portInput.lastValidValue :
639                portInput.value;
640
641     var location = locationInput.classList.contains('invalid') ?
642                    locationInput.lastValidValue :
643                    locationInput.value;
644
645     if (port && location)
646       config[port] = location;
647   }
648   sendCommand('set-port-forwarding-config', config);
649 }
650
651 function updateDiscoverUsbDevicesEnabled(enabled) {
652   var checkbox = $('discover-usb-devices-enable');
653   checkbox.checked = !!enabled;
654   checkbox.disabled = false;
655 }
656
657 function updatePortForwardingEnabled(enabled) {
658   var checkbox = $('port-forwarding-enable');
659   checkbox.checked = !!enabled;
660   checkbox.disabled = false;
661 }
662
663 function updatePortForwardingConfig(config) {
664   window.portForwardingConfig = config;
665   $('port-forwarding-config-open').disabled = !config;
666 }
667
668 function createConfigLine(port, location) {
669   var line = document.createElement('div');
670   line.className = 'port-forwarding-pair';
671
672   var portInput = createConfigField(port, 'port', 'Port', validatePort);
673   line.appendChild(portInput);
674
675   var locationInput = createConfigField(
676       location, 'location', 'IP address and port', validateLocation);
677   line.appendChild(locationInput);
678   locationInput.addEventListener('keydown', function(e) {
679     if (e.keyIdentifier == 'U+0009' &&  // Tab
680         !e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey &&
681         line.classList.contains('fresh') &&
682         !line.classList.contains('empty')) {
683       // Tabbing forward on the fresh line, try create a new empty one.
684       if (commitFreshLineIfValid(true))
685         e.preventDefault();
686     }
687   });
688
689   var lineDelete = document.createElement('div');
690   lineDelete.className = 'close-button';
691   lineDelete.addEventListener('click', function() {
692     var newSelection = line.nextElementSibling;
693     line.parentNode.removeChild(line);
694     selectLine(newSelection);
695   });
696   line.appendChild(lineDelete);
697
698   line.addEventListener('click', selectLine.bind(null, line));
699   line.addEventListener('focus', selectLine.bind(null, line));
700
701   checkEmptyLine(line);
702
703   return line;
704 }
705
706 function validatePort(input) {
707   var match = input.value.match(/^(\d+)$/);
708   if (!match)
709     return false;
710   var port = parseInt(match[1]);
711   if (port < 1024 || 65535 < port)
712     return false;
713
714   var inputs = document.querySelectorAll('input.port:not(.invalid)');
715   for (var i = 0; i != inputs.length; ++i) {
716     if (inputs[i] == input)
717       break;
718     if (parseInt(inputs[i].value) == port)
719       return false;
720   }
721   return true;
722 }
723
724 function validateLocation(input) {
725   var match = input.value.match(/^([a-zA-Z0-9\.]+):(\d+)$/);
726   if (!match)
727     return false;
728   var port = parseInt(match[2]);
729   return port <= 65535;
730 }
731
732 function createEmptyConfigLine() {
733   var line = createConfigLine('', '');
734   line.classList.add('fresh');
735   return line;
736 }
737
738 function createConfigField(value, className, hint, validate) {
739   var input = document.createElement('input');
740   input.className = className;
741   input.type = 'text';
742   input.placeholder = hint;
743   input.value = value;
744   input.lastValidValue = value;
745
746   function checkInput() {
747     if (validate(input))
748       input.classList.remove('invalid');
749     else
750       input.classList.add('invalid');
751     if (input.parentNode)
752       checkEmptyLine(input.parentNode);
753   }
754   checkInput();
755
756   input.addEventListener('keyup', checkInput);
757   input.addEventListener('focus', function() {
758     selectLine(input.parentNode);
759   });
760
761   input.addEventListener('blur', function() {
762     if (validate(input))
763       input.lastValidValue = input.value;
764   });
765
766   return input;
767 }
768
769 function checkEmptyLine(line) {
770   var inputs = line.querySelectorAll('input');
771   var empty = true;
772   for (var i = 0; i != inputs.length; i++) {
773     if (inputs[i].value != '')
774       empty = false;
775   }
776   if (empty)
777     line.classList.add('empty');
778   else
779     line.classList.remove('empty');
780 }
781
782 function selectLine(line) {
783   if (line.classList.contains('selected'))
784     return;
785   unselectLine();
786   line.classList.add('selected');
787 }
788
789 function unselectLine() {
790   var line = document.querySelector('.port-forwarding-pair.selected');
791   if (!line)
792     return;
793   line.classList.remove('selected');
794   commitFreshLineIfValid();
795 }
796
797 function commitFreshLineIfValid(opt_selectNew) {
798   var line = document.querySelector('.port-forwarding-pair.fresh');
799   if (line.querySelector('.invalid'))
800     return false;
801   line.classList.remove('fresh');
802   var freshLine = createEmptyConfigLine();
803   line.parentNode.appendChild(freshLine);
804   if (opt_selectNew)
805     freshLine.querySelector('.port').focus();
806   return true;
807 }
808
809 document.addEventListener('DOMContentLoaded', onload);
810
811 window.addEventListener('hashchange', onHashChange);