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