Sfoglia il codice sorgente

Merge pull request #2707 from overleaf/as-transform-esm

Transform frontend module format from AMD to ESM

GitOrigin-RevId: 9adbcdc95e819a54114010c6fd3521d8f58ef2fe
Alasdair Smith 6 anni fa
parent
commit
8f5270899f
100 ha cambiato i file con 12165 aggiunte e 12340 eliminazioni
  1. 88 88
      services/web/frontend/js/analytics/AbTestingManager.js
  2. 84 86
      services/web/frontend/js/base.js
  3. 78 79
      services/web/frontend/js/components/inputSuggestions.js
  4. 152 153
      services/web/frontend/js/directives/asyncForm.js
  5. 6 7
      services/web/frontend/js/directives/autoSubmitForm.js
  6. 51 52
      services/web/frontend/js/directives/bookmarkableTabset.js
  7. 74 74
      services/web/frontend/js/directives/complexPassword.js
  8. 601 605
      services/web/frontend/js/directives/creditCards.js
  9. 14 13
      services/web/frontend/js/directives/equals.js
  10. 84 60
      services/web/frontend/js/directives/eventTracking.js
  11. 20 19
      services/web/frontend/js/directives/expandableTextArea.js
  12. 79 77
      services/web/frontend/js/directives/fineUpload.js
  13. 72 73
      services/web/frontend/js/directives/focus.js
  14. 32 32
      services/web/frontend/js/directives/mathjax.js
  15. 12 11
      services/web/frontend/js/directives/maxHeight.js
  16. 10 9
      services/web/frontend/js/directives/onEnter.js
  17. 12 11
      services/web/frontend/js/directives/rightClick.js
  18. 42 41
      services/web/frontend/js/directives/scroll.js
  19. 74 77
      services/web/frontend/js/directives/selectAll.js
  20. 13 14
      services/web/frontend/js/directives/stopPropagation.js
  21. 18 17
      services/web/frontend/js/directives/videoPlayState.js
  22. 24 24
      services/web/frontend/js/filters/formatDate.js
  23. 19 20
      services/web/frontend/js/filters/wrapLongWords.js
  24. 299 317
      services/web/frontend/js/ide.js
  25. 76 77
      services/web/frontend/js/ide/SafariScrollPatcher.js
  26. 27 27
      services/web/frontend/js/ide/binary-files/BinaryFilesManager.js
  27. 211 212
      services/web/frontend/js/ide/binary-files/controllers/BinaryFileController.js
  28. 52 51
      services/web/frontend/js/ide/chat/controllers/ChatButtonController.js
  29. 33 31
      services/web/frontend/js/ide/chat/controllers/ChatController.js
  30. 41 39
      services/web/frontend/js/ide/chat/controllers/ChatMessageController.js
  31. 5 7
      services/web/frontend/js/ide/chat/index.js
  32. 123 121
      services/web/frontend/js/ide/chat/services/chatMessages.js
  33. 11 10
      services/web/frontend/js/ide/clone/controllers/CloneProjectController.js
  34. 42 41
      services/web/frontend/js/ide/clone/controllers/CloneProjectModalController.js
  35. 2 4
      services/web/frontend/js/ide/clone/index.js
  36. 34 35
      services/web/frontend/js/ide/cobranding/CobrandingDataService.js
  37. 65 65
      services/web/frontend/js/ide/colors/ColorManager.js
  38. 546 547
      services/web/frontend/js/ide/connection/ConnectionManager.js
  39. 195 197
      services/web/frontend/js/ide/connection/SocketIoShim.js
  40. 40 48
      services/web/frontend/js/ide/directives/SafePath.js
  41. 225 227
      services/web/frontend/js/ide/directives/layout.js
  42. 10 8
      services/web/frontend/js/ide/directives/validFile.js
  43. 624 630
      services/web/frontend/js/ide/editor/Document.js
  44. 269 272
      services/web/frontend/js/ide/editor/EditorManager.js
  45. 34 35
      services/web/frontend/js/ide/editor/EditorShareJsCodec.js
  46. 340 345
      services/web/frontend/js/ide/editor/ShareJsDoc.js
  47. 32 31
      services/web/frontend/js/ide/editor/components/spellMenu.js
  48. 75 73
      services/web/frontend/js/ide/editor/controllers/SavingNotificationController.js
  49. 863 887
      services/web/frontend/js/ide/editor/directives/aceEditor.js
  50. 403 415
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.js
  51. 180 182
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/CommandManager.js
  52. 143 144
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.js
  53. 47 51
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/Helpers.js
  54. 126 128
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/PackageManager.js
  55. 30 32
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/snippets/Environments.js
  56. 2 2
      services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/snippets/TopHundredSnippets.js
  57. 44 47
      services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.js
  58. 83 88
      services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js
  59. 331 340
      services/web/frontend/js/ide/editor/directives/aceEditor/highlights/HighlightsManager.js
  60. 74 78
      services/web/frontend/js/ide/editor/directives/aceEditor/metadata/MetadataManager.js
  61. 108 114
      services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.js
  62. 77 80
      services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.js
  63. 2 7
      services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js
  64. 203 211
      services/web/frontend/js/ide/editor/directives/aceEditor/track-changes/TrackChangesAdapter.js
  65. 569 579
      services/web/frontend/js/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.js
  66. 26 27
      services/web/frontend/js/ide/editor/directives/aceEditor/undo/UndoManager.js
  67. 32 31
      services/web/frontend/js/ide/editor/directives/toggleSwitch.js
  68. 611 618
      services/web/frontend/js/ide/file-tree/FileTreeManager.js
  69. 547 553
      services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js
  70. 121 124
      services/web/frontend/js/ide/file-tree/controllers/FileTreeEntityController.js
  71. 101 103
      services/web/frontend/js/ide/file-tree/controllers/FileTreeFolderController.js
  72. 35 32
      services/web/frontend/js/ide/file-tree/controllers/FileTreeRootFolderController.js
  73. 16 15
      services/web/frontend/js/ide/file-tree/directives/draggable.js
  74. 17 16
      services/web/frontend/js/ide/file-tree/directives/droppable.js
  75. 31 30
      services/web/frontend/js/ide/file-tree/directives/fileEntity.js
  76. 14 11
      services/web/frontend/js/ide/file-tree/util/fileOperationI18nNames.js
  77. 16 16
      services/web/frontend/js/ide/file-tree/util/iconTypeFromName.js
  78. 20 19
      services/web/frontend/js/ide/files/services/files.js
  79. 24 23
      services/web/frontend/js/ide/graphics/services/graphics.js
  80. 318 325
      services/web/frontend/js/ide/history/HistoryManager.js
  81. 831 842
      services/web/frontend/js/ide/history/HistoryV2Manager.js
  82. 113 114
      services/web/frontend/js/ide/history/components/historyEntriesList.js
  83. 97 100
      services/web/frontend/js/ide/history/components/historyEntry.js
  84. 82 87
      services/web/frontend/js/ide/history/components/historyFileEntity.js
  85. 45 46
      services/web/frontend/js/ide/history/components/historyFileTree.js
  86. 22 29
      services/web/frontend/js/ide/history/components/historyLabel.js
  87. 143 149
      services/web/frontend/js/ide/history/components/historyLabelsList.js
  88. 52 53
      services/web/frontend/js/ide/history/controllers/HistoryDiffController.js
  89. 162 168
      services/web/frontend/js/ide/history/controllers/HistoryListController.js
  90. 36 35
      services/web/frontend/js/ide/history/controllers/HistoryV2AddLabelModalController.js
  91. 32 31
      services/web/frontend/js/ide/history/controllers/HistoryV2DeleteLabelModalController.js
  92. 10 6
      services/web/frontend/js/ide/history/controllers/HistoryV2FileTreeController.js
  93. 35 33
      services/web/frontend/js/ide/history/controllers/HistoryV2ListController.js
  94. 95 94
      services/web/frontend/js/ide/history/controllers/HistoryV2ToolbarController.js
  95. 33 32
      services/web/frontend/js/ide/history/directives/historyDraggableBoundary.js
  96. 24 25
      services/web/frontend/js/ide/history/directives/historyDroppableArea.js
  97. 34 33
      services/web/frontend/js/ide/history/directives/infiniteScroll.js
  98. 5 6
      services/web/frontend/js/ide/history/util/HistoryViewModes.js
  99. 23 24
      services/web/frontend/js/ide/history/util/displayNameForUser.js
  100. 7 13
      services/web/frontend/js/ide/hotkeys/BackspaceHighjack.js

+ 88 - 88
services/web/frontend/js/analytics/AbTestingManager.js

@@ -12,107 +12,107 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base', 'crypto-js/md5'], function(App, CryptoJS) {
-  const oldKeys = [
-    'sl_abt_multi_currency_editor_eu-eu',
-    'sl_abt_multi_currency_eu-eu',
-    'sl_abt_multi_currency_editor_eu-usd',
-    'sl_abt_multi_currency_eu-usd',
-    'sl_abt_trial_len_14d',
-    'sl_abt_trial_len_7d',
-    'sl_abt_trial_len_30d',
-    'sl_utt',
-    'sl_utt_trial_len',
-    'sl_utt_multi_currency'
-  ]
+import App from '../base'
+import CryptoJS from 'crypto-js/md5'
+const oldKeys = [
+  'sl_abt_multi_currency_editor_eu-eu',
+  'sl_abt_multi_currency_eu-eu',
+  'sl_abt_multi_currency_editor_eu-usd',
+  'sl_abt_multi_currency_eu-usd',
+  'sl_abt_trial_len_14d',
+  'sl_abt_trial_len_7d',
+  'sl_abt_trial_len_30d',
+  'sl_utt',
+  'sl_utt_trial_len',
+  'sl_utt_multi_currency'
+]
 
-  App.factory('abTestManager', function($http, ipCookie) {
-    let getABTestBucket, processTestWithStep
-    _.each(oldKeys, oldKey => ipCookie.remove(oldKey))
+App.factory('abTestManager', function($http, ipCookie) {
+  let getABTestBucket, processTestWithStep
+  _.each(oldKeys, oldKey => ipCookie.remove(oldKey))
 
-    const _buildCookieKey = function(testName, bucket) {
-      const key = `sl_abt_${testName}_${bucket}`
-      return key
-    }
+  const _buildCookieKey = function(testName, bucket) {
+    const key = `sl_abt_${testName}_${bucket}`
+    return key
+  }
 
-    const _getTestCookie = function(testName, bucket) {
-      const cookieKey = _buildCookieKey(testName, bucket)
-      const cookie = ipCookie(cookieKey)
-      return cookie
-    }
+  const _getTestCookie = function(testName, bucket) {
+    const cookieKey = _buildCookieKey(testName, bucket)
+    const cookie = ipCookie(cookieKey)
+    return cookie
+  }
 
-    const _persistCookieStep = function(testName, bucket, newStep) {
-      const cookieKey = _buildCookieKey(testName, bucket)
-      ipCookie(cookieKey, { step: newStep }, { expires: 100, path: '/' })
-      return ga(
-        'send',
-        'event',
-        'ab_tests',
-        `${testName}:${bucket}`,
-        `step-${newStep}`
-      )
-    }
+  const _persistCookieStep = function(testName, bucket, newStep) {
+    const cookieKey = _buildCookieKey(testName, bucket)
+    ipCookie(cookieKey, { step: newStep }, { expires: 100, path: '/' })
+    return ga(
+      'send',
+      'event',
+      'ab_tests',
+      `${testName}:${bucket}`,
+      `step-${newStep}`
+    )
+  }
 
-    const _checkIfStepIsNext = function(cookieStep, newStep) {
-      if (cookieStep == null && newStep !== 0) {
-        return false
-      } else if (newStep === 0) {
-        return true
-      } else if (cookieStep + 1 === newStep) {
-        return true
-      } else {
-        return false
-      }
+  const _checkIfStepIsNext = function(cookieStep, newStep) {
+    if (cookieStep == null && newStep !== 0) {
+      return false
+    } else if (newStep === 0) {
+      return true
+    } else if (cookieStep + 1 === newStep) {
+      return true
+    } else {
+      return false
     }
+  }
 
-    const _getUsersHash = function(testName) {
-      const sl_user_test_token = `sl_utt_${testName}`
-      let user_uuid = ipCookie(sl_user_test_token)
-      if (user_uuid == null) {
-        user_uuid = Math.random()
-        ipCookie(sl_user_test_token, user_uuid, { expires: 365, path: '/' })
-      }
-      const hash = CryptoJS(`${user_uuid}:${testName}`)
-      return hash
+  const _getUsersHash = function(testName) {
+    const sl_user_test_token = `sl_utt_${testName}`
+    let user_uuid = ipCookie(sl_user_test_token)
+    if (user_uuid == null) {
+      user_uuid = Math.random()
+      ipCookie(sl_user_test_token, user_uuid, { expires: 365, path: '/' })
     }
+    const hash = CryptoJS(`${user_uuid}:${testName}`)
+    return hash
+  }
 
-    return {
-      processTestWithStep: (processTestWithStep = function(
-        testName,
-        bucket,
-        newStep
-      ) {
-        const currentCookieStep = __guard__(
-          _getTestCookie(testName, bucket),
-          x => x.step
-        )
-        if (_checkIfStepIsNext(currentCookieStep, newStep)) {
-          return _persistCookieStep(testName, bucket, newStep)
-        }
-      }),
+  return {
+    processTestWithStep: (processTestWithStep = function(
+      testName,
+      bucket,
+      newStep
+    ) {
+      const currentCookieStep = __guard__(
+        _getTestCookie(testName, bucket),
+        x => x.step
+      )
+      if (_checkIfStepIsNext(currentCookieStep, newStep)) {
+        return _persistCookieStep(testName, bucket, newStep)
+      }
+    }),
 
-      getABTestBucket: (getABTestBucket = function(test_name, buckets) {
-        const hash = _getUsersHash(test_name)
-        const bucketIndex =
-          parseInt(hash.toString().slice(0, 2), 16) %
-          ((buckets != null ? buckets.length : undefined) || 2)
-        return buckets[bucketIndex]
-      })
-    }
-  })
+    getABTestBucket: (getABTestBucket = function(test_name, buckets) {
+      const hash = _getUsersHash(test_name)
+      const bucketIndex =
+        parseInt(hash.toString().slice(0, 2), 16) %
+        ((buckets != null ? buckets.length : undefined) || 2)
+      return buckets[bucketIndex]
+    })
+  }
+})
 
-  return App.controller('AbTestController', function($scope, abTestManager) {
-    const testKeys = _.keys(window.ab)
+export default App.controller('AbTestController', function(
+  $scope,
+  abTestManager
+) {
+  const testKeys = _.keys(window.ab)
 
-    return _.each(window.ab, event =>
-      abTestManager.processTestWithStep(
-        event.testName,
-        event.bucket,
-        event.step
-      )
-    )
-  })
+  return _.each(window.ab, event =>
+    abTestManager.processTestWithStep(event.testName, event.bucket, event.step)
+  )
 })
+
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null
     ? transform(value)

+ 84 - 86
services/web/frontend/js/base.js

@@ -16,102 +16,100 @@
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 
+import './libraries'
+import './utils/sentry'
+import './modules/recursionHelper'
+import './modules/errorCatcher'
+import './modules/localStorage'
+import './modules/sessionStorage'
+import './utils/underscore'
 // Configure dynamically loaded assets (via webpack) to be downloaded from CDN
 // See: https://webpack.js.org/guides/public-path/#on-the-fly
-__webpack_public_path__ = window.baseAssetPath // eslint-disable-line no-undef
+// eslint-disable-next-line no-undef
+__webpack_public_path__ = window.baseAssetPath
 
-define([
-  './libraries',
-  './utils/sentry',
-  './modules/recursionHelper',
-  './modules/errorCatcher',
-  './modules/localStorage',
-  './modules/sessionStorage',
-  './utils/underscore'
-], function() {
-  const App = angular
-    .module('SharelatexApp', [
-      'ui.bootstrap',
-      'autocomplete',
-      'RecursionHelper',
-      'ng-context-menu',
-      'underscore',
-      'ngSanitize',
-      'ipCookie',
-      'ErrorCatcher',
-      'localStorage',
-      'sessionStorage',
-      'ngTagsInput',
-      'ui.select'
-    ])
-    .config(function($qProvider, $httpProvider, uiSelectConfig) {
-      $qProvider.errorOnUnhandledRejections(false)
-      uiSelectConfig.spinnerClass = 'fa fa-refresh ui-select-spin'
+const App = angular
+  .module('SharelatexApp', [
+    'ui.bootstrap',
+    'autocomplete',
+    'RecursionHelper',
+    'ng-context-menu',
+    'underscore',
+    'ngSanitize',
+    'ipCookie',
+    'ErrorCatcher',
+    'localStorage',
+    'sessionStorage',
+    'ngTagsInput',
+    'ui.select'
+  ])
+  .config(function($qProvider, $httpProvider, uiSelectConfig) {
+    $qProvider.errorOnUnhandledRejections(false)
+    uiSelectConfig.spinnerClass = 'fa fa-refresh ui-select-spin'
 
-      return __guard__(
-        typeof MathJax !== 'undefined' && MathJax !== null
-          ? MathJax.Hub
-          : undefined,
-        x =>
-          x.Config({
-            messageStyle: 'none',
-            imageFont: null,
-            'HTML-CSS': {
-              availableFonts: ['TeX'],
-              // MathJax's automatic font scaling does not work well when we render math
-              // that isn't yet on the page, so we disable it and set a global font
-              // scale factor
-              scale: 110,
-              matchFontHeight: false
-            },
-            TeX: {
-              equationNumbers: { autoNumber: 'AMS' },
-              useLabelIDs: false
-            },
-            skipStartupTypeset: true,
-            tex2jax: {
-              processEscapes: true,
-              // Dollar delimiters are added by the mathjax directive
-              inlineMath: [['\\(', '\\)']],
-              displayMath: [['$$', '$$'], ['\\[', '\\]']]
-            }
-          })
-      )
-    })
-
-  App.run($templateCache =>
-    // UI Select templates are hard-coded and use Glyphicon icons (which we don't import).
-    // The line below simply overrides the hard-coded template with our own, which is
-    // basically the same but using Font Awesome icons.
-    $templateCache.put(
-      'bootstrap/match.tpl.html',
-      '<div class="ui-select-match" ng-hide="$select.open && $select.searchEnabled" ng-disabled="$select.disabled" ng-class="{\'btn-default-focus\':$select.focus}"><span tabindex="-1" class="btn btn-default form-control ui-select-toggle" aria-label="{{ $select.baseTitle }} activate" ng-disabled="$select.disabled" ng-click="$select.activate()" style="outline: 0;"><span ng-show="$select.isEmpty()" class="ui-select-placeholder text-muted">{{$select.placeholder}}</span> <span ng-hide="$select.isEmpty()" class="ui-select-match-text pull-left" ng-class="{\'ui-select-allow-clear\': $select.allowClear && !$select.isEmpty()}" ng-transclude=""></span> <i class="caret pull-right" ng-click="$select.toggle($event)"></i> <a ng-show="$select.allowClear && !$select.isEmpty() && ($select.disabled !== true)" aria-label="{{ $select.baseTitle }} clear" style="margin-right: 10px" ng-click="$select.clear($event)" class="btn btn-xs btn-link pull-right"><i class="fa fa-times" aria-hidden="true"></i></a></span></div>'
+    return __guard__(
+      typeof MathJax !== 'undefined' && MathJax !== null
+        ? MathJax.Hub
+        : undefined,
+      x =>
+        x.Config({
+          messageStyle: 'none',
+          imageFont: null,
+          'HTML-CSS': {
+            availableFonts: ['TeX'],
+            // MathJax's automatic font scaling does not work well when we render math
+            // that isn't yet on the page, so we disable it and set a global font
+            // scale factor
+            scale: 110,
+            matchFontHeight: false
+          },
+          TeX: {
+            equationNumbers: { autoNumber: 'AMS' },
+            useLabelIDs: false
+          },
+          skipStartupTypeset: true,
+          tex2jax: {
+            processEscapes: true,
+            // Dollar delimiters are added by the mathjax directive
+            inlineMath: [['\\(', '\\)']],
+            displayMath: [['$$', '$$'], ['\\[', '\\]']]
+          }
+        })
     )
+  })
+
+App.run($templateCache =>
+  // UI Select templates are hard-coded and use Glyphicon icons (which we don't import).
+  // The line below simply overrides the hard-coded template with our own, which is
+  // basically the same but using Font Awesome icons.
+  $templateCache.put(
+    'bootstrap/match.tpl.html',
+    '<div class="ui-select-match" ng-hide="$select.open && $select.searchEnabled" ng-disabled="$select.disabled" ng-class="{\'btn-default-focus\':$select.focus}"><span tabindex="-1" class="btn btn-default form-control ui-select-toggle" aria-label="{{ $select.baseTitle }} activate" ng-disabled="$select.disabled" ng-click="$select.activate()" style="outline: 0;"><span ng-show="$select.isEmpty()" class="ui-select-placeholder text-muted">{{$select.placeholder}}</span> <span ng-hide="$select.isEmpty()" class="ui-select-match-text pull-left" ng-class="{\'ui-select-allow-clear\': $select.allowClear && !$select.isEmpty()}" ng-transclude=""></span> <i class="caret pull-right" ng-click="$select.toggle($event)"></i> <a ng-show="$select.allowClear && !$select.isEmpty() && ($select.disabled !== true)" aria-label="{{ $select.baseTitle }} clear" style="margin-right: 10px" ng-click="$select.clear($event)" class="btn btn-xs btn-link pull-right"><i class="fa fa-times" aria-hidden="true"></i></a></span></div>'
   )
+)
 
-  const sl_debugging =
-    __guard__(window.location != null ? window.location.search : undefined, x =>
-      x.match(/debug=true/)
-    ) != null
-  var sl_console_last_log = null
-  window.sl_debugging = sl_debugging // make a global flag for debugging code
-  window.sl_console = {
-    log(...args) {
-      if (sl_debugging) {
-        sl_console_last_log = null
-        return console.log(...Array.from(args || []))
-      }
-    },
-    logOnce(...args) {
-      if (sl_debugging && args[0] !== sl_console_last_log) {
-        sl_console_last_log = args[0]
-        return console.log(...Array.from(args || []))
-      }
+const sl_debugging =
+  __guard__(window.location != null ? window.location.search : undefined, x =>
+    x.match(/debug=true/)
+  ) != null
+var sl_console_last_log = null
+window.sl_debugging = sl_debugging // make a global flag for debugging code
+window.sl_console = {
+  log(...args) {
+    if (sl_debugging) {
+      sl_console_last_log = null
+      return console.log(...Array.from(args || []))
+    }
+  },
+  logOnce(...args) {
+    if (sl_debugging && args[0] !== sl_console_last_log) {
+      sl_console_last_log = args[0]
+      return console.log(...Array.from(args || []))
     }
   }
+}
 
-  return App
-})
+export default App
 
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null

+ 78 - 79
services/web/frontend/js/components/inputSuggestions.js

@@ -10,89 +10,88 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], function(App) {
-  const inputSuggestionsController = function($scope, $element, $attrs, Keys) {
-    const ctrl = this
+import App from '../base'
+const inputSuggestionsController = function($scope, $element, $attrs, Keys) {
+  const ctrl = this
+  ctrl.showHint = false
+  ctrl.hasFocus = false
+  ctrl.handleFocus = function() {
+    ctrl.hasFocus = true
+    return (ctrl.suggestion = null)
+  }
+  ctrl.handleBlur = function() {
     ctrl.showHint = false
     ctrl.hasFocus = false
-    ctrl.handleFocus = function() {
-      ctrl.hasFocus = true
-      return (ctrl.suggestion = null)
-    }
-    ctrl.handleBlur = function() {
-      ctrl.showHint = false
-      ctrl.hasFocus = false
-      ctrl.suggestion = null
-      return ctrl.onBlur()
+    ctrl.suggestion = null
+    return ctrl.onBlur()
+  }
+  ctrl.handleKeyDown = function($event) {
+    if (
+      ($event.which === Keys.TAB || $event.which === Keys.ENTER) &&
+      ctrl.suggestion != null &&
+      ctrl.suggestion !== ''
+    ) {
+      $event.preventDefault()
+      ctrl.localNgModel += ctrl.suggestion
     }
-    ctrl.handleKeyDown = function($event) {
-      if (
-        ($event.which === Keys.TAB || $event.which === Keys.ENTER) &&
-        ctrl.suggestion != null &&
-        ctrl.suggestion !== ''
-      ) {
-        $event.preventDefault()
-        ctrl.localNgModel += ctrl.suggestion
-      }
+    ctrl.suggestion = null
+    return (ctrl.showHint = false)
+  }
+  $scope.$watch('$ctrl.localNgModel', function(newVal, oldVal) {
+    if (ctrl.hasFocus && newVal !== oldVal) {
       ctrl.suggestion = null
-      return (ctrl.showHint = false)
+      ctrl.showHint = false
+      return ctrl
+        .getSuggestion({ userInput: newVal })
+        .then(function(suggestion) {
+          if (suggestion != null && newVal === ctrl.localNgModel) {
+            ctrl.showHint = true
+            return (ctrl.suggestion = suggestion.replace(newVal, ''))
+          }
+        })
+        .catch(() => (ctrl.suggestion = null))
     }
-    $scope.$watch('$ctrl.localNgModel', function(newVal, oldVal) {
-      if (ctrl.hasFocus && newVal !== oldVal) {
-        ctrl.suggestion = null
-        ctrl.showHint = false
-        return ctrl
-          .getSuggestion({ userInput: newVal })
-          .then(function(suggestion) {
-            if (suggestion != null && newVal === ctrl.localNgModel) {
-              ctrl.showHint = true
-              return (ctrl.suggestion = suggestion.replace(newVal, ''))
-            }
-          })
-          .catch(() => (ctrl.suggestion = null))
-      }
-    })
-  }
-
-  return App.component('inputSuggestions', {
-    bindings: {
-      localNgModel: '=ngModel',
-      localNgModelOptions: '=?ngModelOptions',
-      getSuggestion: '&',
-      onBlur: '&?',
-      inputId: '@?',
-      inputName: '@?',
-      inputPlaceholder: '@?',
-      inputType: '@?',
-      inputRequired: '=?'
-    },
-    controller: inputSuggestionsController,
-    template: [
-      '<div class="input-suggestions">',
-      '<div class="form-control input-suggestions-shadow">',
-      '<span ng-bind="$ctrl.localNgModel"',
-      ' class="input-suggestions-shadow-existing"',
-      ' ng-show="$ctrl.showHint">',
-      '</span>',
-      '<span ng-bind="$ctrl.suggestion"',
-      ' class="input-suggestions-shadow-suggested"',
-      ' ng-show="$ctrl.showHint">',
-      '</span>',
-      '</div>',
-      '<input type="text"',
-      ' class="form-control input-suggestions-main"',
-      ' ng-focus="$ctrl.handleFocus()"',
-      ' ng-keyDown="$ctrl.handleKeyDown($event)"',
-      ' ng-blur="$ctrl.handleBlur()"',
-      ' ng-model="$ctrl.localNgModel"',
-      ' ng-model-options="$ctrl.localNgModelOptions"',
-      ' ng-model-options="{ debounce: 50 }"',
-      ' ng-attr-id="{{ ::$ctrl.inputId }}"',
-      ' ng-attr-placeholder="{{ ::$ctrl.inputPlaceholder }}"',
-      ' ng-attr-type="{{ ::$ctrl.inputType }}"',
-      ' ng-attr-name="{{ ::$ctrl.inputName }}"',
-      ' ng-required="::$ctrl.inputRequired">',
-      '</div>'
-    ].join('')
   })
+}
+
+export default App.component('inputSuggestions', {
+  bindings: {
+    localNgModel: '=ngModel',
+    localNgModelOptions: '=?ngModelOptions',
+    getSuggestion: '&',
+    onBlur: '&?',
+    inputId: '@?',
+    inputName: '@?',
+    inputPlaceholder: '@?',
+    inputType: '@?',
+    inputRequired: '=?'
+  },
+  controller: inputSuggestionsController,
+  template: [
+    '<div class="input-suggestions">',
+    '<div class="form-control input-suggestions-shadow">',
+    '<span ng-bind="$ctrl.localNgModel"',
+    ' class="input-suggestions-shadow-existing"',
+    ' ng-show="$ctrl.showHint">',
+    '</span>',
+    '<span ng-bind="$ctrl.suggestion"',
+    ' class="input-suggestions-shadow-suggested"',
+    ' ng-show="$ctrl.showHint">',
+    '</span>',
+    '</div>',
+    '<input type="text"',
+    ' class="form-control input-suggestions-main"',
+    ' ng-focus="$ctrl.handleFocus()"',
+    ' ng-keyDown="$ctrl.handleKeyDown($event)"',
+    ' ng-blur="$ctrl.handleBlur()"',
+    ' ng-model="$ctrl.localNgModel"',
+    ' ng-model-options="$ctrl.localNgModelOptions"',
+    ' ng-model-options="{ debounce: 50 }"',
+    ' ng-attr-id="{{ ::$ctrl.inputId }}"',
+    ' ng-attr-placeholder="{{ ::$ctrl.inputPlaceholder }}"',
+    ' ng-attr-type="{{ ::$ctrl.inputType }}"',
+    ' ng-attr-name="{{ ::$ctrl.inputName }}"',
+    ' ng-required="::$ctrl.inputRequired">',
+    '</div>'
+  ].join('')
 })

+ 152 - 153
services/web/frontend/js/directives/asyncForm.js

@@ -12,176 +12,175 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base', 'libs/passfield'], function(App) {
-  App.directive('asyncForm', ($http, validateCaptcha, validateCaptchaV3) => ({
-    controller: [
-      '$scope',
-      function($scope) {
-        this.getEmail = () => $scope.email
-        return this
+import App from '../base'
+import 'libs/passfield'
+App.directive('asyncForm', ($http, validateCaptcha, validateCaptchaV3) => ({
+  controller: [
+    '$scope',
+    function($scope) {
+      this.getEmail = () => $scope.email
+      return this
+    }
+  ],
+  link(scope, element, attrs) {
+    let response
+    const formName = attrs.asyncForm
+
+    scope[attrs.name].response = response = {}
+    scope[attrs.name].inflight = false
+
+    const validateCaptchaIfEnabled = function(callback) {
+      if (callback == null) {
+        callback = function(response) {}
+      }
+      if (attrs.captchaActionName) {
+        validateCaptchaV3(attrs.captchaActionName)
+      }
+      if (attrs.captcha != null) {
+        return validateCaptcha(callback)
+      } else {
+        return callback()
+      }
+    }
+
+    const submitRequest = function(grecaptchaResponse) {
+      const formData = {}
+      for (var data of Array.from(element.serializeArray())) {
+        formData[data.name] = data.value
       }
-    ],
-    link(scope, element, attrs) {
-      let response
-      const formName = attrs.asyncForm
-
-      scope[attrs.name].response = response = {}
-      scope[attrs.name].inflight = false
-
-      const validateCaptchaIfEnabled = function(callback) {
-        if (callback == null) {
-          callback = function(response) {}
-        }
-        if (attrs.captchaActionName) {
-          validateCaptchaV3(attrs.captchaActionName)
-        }
-        if (attrs.captcha != null) {
-          return validateCaptcha(callback)
-        } else {
-          return callback()
-        }
+
+      if (grecaptchaResponse != null) {
+        formData['g-recaptcha-response'] = grecaptchaResponse
       }
 
-      const submitRequest = function(grecaptchaResponse) {
-        const formData = {}
-        for (var data of Array.from(element.serializeArray())) {
-          formData[data.name] = data.value
-        }
-
-        if (grecaptchaResponse != null) {
-          formData['g-recaptcha-response'] = grecaptchaResponse
-        }
-
-        // clear the response object which may be referenced downstream
-        Object.keys(response).forEach(field => delete response[field])
-        scope[attrs.name].inflight = true
-
-        // for asyncForm prevent automatic redirect to /login if
-        // authentication fails, we will handle it ourselves
-        const httpRequestFn = _httpRequestFn(element.attr('method'))
-        return httpRequestFn(element.attr('action'), formData, {
-          disableAutoLoginRedirect: true
+      // clear the response object which may be referenced downstream
+      Object.keys(response).forEach(field => delete response[field])
+      scope[attrs.name].inflight = true
+
+      // for asyncForm prevent automatic redirect to /login if
+      // authentication fails, we will handle it ourselves
+      const httpRequestFn = _httpRequestFn(element.attr('method'))
+      return httpRequestFn(element.attr('action'), formData, {
+        disableAutoLoginRedirect: true
+      })
+        .then(function(httpResponse) {
+          const { data, headers } = httpResponse
+          scope[attrs.name].inflight = false
+          response.success = true
+          response.error = false
+
+          const onSuccessHandler = scope[attrs.onSuccess]
+          if (onSuccessHandler) {
+            onSuccessHandler(httpResponse)
+            return
+          }
+
+          if (data.redir != null) {
+            ga('send', 'event', formName, 'success')
+            return (window.location = data.redir)
+          } else if (data.message != null) {
+            response.message = data.message
+
+            if (data.message.type === 'error') {
+              response.success = false
+              response.error = true
+              return ga('send', 'event', formName, 'failure', data.message)
+            } else {
+              return ga('send', 'event', formName, 'success')
+            }
+          } else if (scope.$eval(attrs.asyncFormDownloadResponse)) {
+            const blob = new Blob([data], {
+              type: headers('Content-Type')
+            })
+            location.href = URL.createObjectURL(blob) // Trigger file save
+          }
         })
-          .then(function(httpResponse) {
-            const { data, headers } = httpResponse
-            scope[attrs.name].inflight = false
-            response.success = true
-            response.error = false
-
-            const onSuccessHandler = scope[attrs.onSuccess]
-            if (onSuccessHandler) {
-              onSuccessHandler(httpResponse)
-              return
+        .catch(function(httpResponse) {
+          let config, headers, status
+          ;({ data, status, headers, config } = httpResponse)
+          scope[attrs.name].inflight = false
+          response.success = false
+          response.error = true
+          response.status = status
+          response.data = data
+
+          const onErrorHandler = scope[attrs.onError]
+          if (onErrorHandler) {
+            onErrorHandler(httpResponse)
+            return
+          }
+
+          if (status === 400 && data.accountLinkingError) {
+            // Bad Request for account linking
+            response.message = {
+              text: data.accountLinkingError,
+              type: 'error'
             }
-
-            if (data.redir != null) {
-              ga('send', 'event', formName, 'success')
-              return (window.location = data.redir)
-            } else if (data.message != null) {
-              response.message = data.message
-
-              if (data.message.type === 'error') {
-                response.success = false
-                response.error = true
-                return ga('send', 'event', formName, 'failure', data.message)
-              } else {
-                return ga('send', 'event', formName, 'success')
-              }
-            } else if (scope.$eval(attrs.asyncFormDownloadResponse)) {
-              const blob = new Blob([data], {
-                type: headers('Content-Type')
-              })
-              location.href = URL.createObjectURL(blob) // Trigger file save
+          } else if (status === 400) {
+            // Bad Request
+            response.message = {
+              text: 'Invalid Request. Please correct the data and try again.',
+              type: 'error'
             }
-          })
-          .catch(function(httpResponse) {
-            let config, headers, status
-            ;({ data, status, headers, config } = httpResponse)
-            scope[attrs.name].inflight = false
-            response.success = false
-            response.error = true
-            response.status = status
-            response.data = data
-
-            const onErrorHandler = scope[attrs.onError]
-            if (onErrorHandler) {
-              onErrorHandler(httpResponse)
-              return
+          } else if (status === 403) {
+            // Forbidden
+            response.message = {
+              text:
+                'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
+              type: 'error'
             }
-
-            if (status === 400 && data.accountLinkingError) {
-              // Bad Request for account linking
-              response.message = {
-                text: data.accountLinkingError,
-                type: 'error'
-              }
-            } else if (status === 400) {
-              // Bad Request
-              response.message = {
-                text: 'Invalid Request. Please correct the data and try again.',
-                type: 'error'
-              }
-            } else if (status === 403) {
-              // Forbidden
-              response.message = {
-                text:
-                  'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
-                type: 'error'
-              }
-            } else if (status === 429) {
-              response.message = {
-                text:
-                  'Too many attempts. Please wait for a while and try again.',
-                type: 'error'
-              }
-            } else {
-              response.message = {
-                text:
-                  (data.message != null ? data.message.text : undefined) ||
-                  data.message ||
-                  'Something went wrong talking to the server :(. Please try again.',
-                type: 'error'
-              }
+          } else if (status === 429) {
+            response.message = {
+              text: 'Too many attempts. Please wait for a while and try again.',
+              type: 'error'
             }
-            return ga('send', 'event', formName, 'failure', data.message)
-          })
-      }
+          } else {
+            response.message = {
+              text:
+                (data.message != null ? data.message.text : undefined) ||
+                data.message ||
+                'Something went wrong talking to the server :(. Please try again.',
+              type: 'error'
+            }
+          }
+          return ga('send', 'event', formName, 'failure', data.message)
+        })
+    }
 
-      const submit = () =>
-        validateCaptchaIfEnabled(response => submitRequest(response))
+    const submit = () =>
+      validateCaptchaIfEnabled(response => submitRequest(response))
 
-      const _httpRequestFn = (method = 'post') => {
-        const $HTTP_FNS = {
-          post: $http.post,
-          get: $http.get
-        }
-        return $HTTP_FNS[method.toLowerCase()]
+    const _httpRequestFn = (method = 'post') => {
+      const $HTTP_FNS = {
+        post: $http.post,
+        get: $http.get
       }
+      return $HTTP_FNS[method.toLowerCase()]
+    }
 
-      element.on('submit', function(e) {
-        e.preventDefault()
-        return submit()
-      })
+    element.on('submit', function(e) {
+      e.preventDefault()
+      return submit()
+    })
 
-      if (attrs.autoSubmit) {
-        return submit()
-      }
+    if (attrs.autoSubmit) {
+      return submit()
     }
-  }))
+  }
+}))
 
-  App.directive('formMessages', () => ({
-    restrict: 'E',
-    template: `\
+App.directive('formMessages', () => ({
+  restrict: 'E',
+  template: `\
 <div class="alert" ng-class="{
-  'alert-danger': form.response.message.type == 'error',
-  'alert-success': form.response.message.type != 'error'
+'alert-danger': form.response.message.type == 'error',
+'alert-success': form.response.message.type != 'error'
 }" ng-show="!!form.response.message" ng-bind-html="form.response.message.text">
 </div>
 <div ng-transclude></div>\
 `,
-    transclude: true,
-    scope: {
-      form: '=for'
-    }
-  }))
-})
+  transclude: true,
+  scope: {
+    form: '=for'
+  }
+}))

+ 6 - 7
services/web/frontend/js/directives/autoSubmitForm.js

@@ -1,9 +1,8 @@
-define(['../base'], function(App) {
-  App.directive('autoSubmitForm', function() {
-    return {
-      link(scope, element) {
-        element.submit() // Runs on load
-      }
+import App from '../base'
+App.directive('autoSubmitForm', function() {
+  return {
+    link(scope, element) {
+      element.submit() // Runs on load
     }
-  })
+  }
 })

+ 51 - 52
services/web/frontend/js/directives/bookmarkableTabset.js

@@ -1,61 +1,60 @@
-define(['../base'], function(App) {
-  App.directive('bookmarkableTabset', ($location, _) => ({
-    restrict: 'A',
-    require: 'tabset',
-    link(scope, el, attrs, tabset) {
-      const _makeActive = function(hash) {
-        if (hash && hash !== '') {
-          const matchingTab = _.find(
-            tabset.tabs,
-            tab => tab.bookmarkableTabId === hash
-          )
-          if (matchingTab) {
-            matchingTab.select()
-            return el.children()[0].scrollIntoView({ behavior: 'smooth' })
-          }
+import App from '../base'
+App.directive('bookmarkableTabset', ($location, _) => ({
+  restrict: 'A',
+  require: 'tabset',
+  link(scope, el, attrs, tabset) {
+    const _makeActive = function(hash) {
+      if (hash && hash !== '') {
+        const matchingTab = _.find(
+          tabset.tabs,
+          tab => tab.bookmarkableTabId === hash
+        )
+        if (matchingTab) {
+          matchingTab.select()
+          return el.children()[0].scrollIntoView({ behavior: 'smooth' })
         }
       }
+    }
+
+    scope.$applyAsync(function() {
+      // for page load
+      const hash = $location.hash()
+      _makeActive(hash)
 
-      scope.$applyAsync(function() {
-        // for page load
-        const hash = $location.hash()
+      // for links within page to a tab
+      // this needs to be within applyAsync because there could be a link
+      // within a tab to another tab
+      const linksToTabs = document.querySelectorAll('.link-to-tab')
+      const _clickLinkToTab = event => {
+        const hash = event.currentTarget
+          .getAttribute('href')
+          .split('#')
+          .pop()
         _makeActive(hash)
+      }
 
-        // for links within page to a tab
-        // this needs to be within applyAsync because there could be a link
-        // within a tab to another tab
-        const linksToTabs = document.querySelectorAll('.link-to-tab')
-        const _clickLinkToTab = event => {
-          const hash = event.currentTarget
-            .getAttribute('href')
-            .split('#')
-            .pop()
-          _makeActive(hash)
-        }
+      if (linksToTabs) {
+        Array.from(linksToTabs).map(link =>
+          link.addEventListener('click', _clickLinkToTab)
+        )
+      }
+    })
+  }
+}))
 
-        if (linksToTabs) {
-          Array.from(linksToTabs).map(link =>
-            link.addEventListener('click', _clickLinkToTab)
-          )
+App.directive('bookmarkableTab', $location => ({
+  restrict: 'A',
+  require: 'tab',
+  link(scope, el, attrs, tab) {
+    const tabScope = el.isolateScope()
+    const tabId = attrs.bookmarkableTab
+    if (tabScope && tabId && tabId !== '') {
+      tabScope.bookmarkableTabId = tabId
+      tabScope.$watch('active', function(isActive, wasActive) {
+        if (isActive && !wasActive && $location.hash() !== tabId) {
+          return $location.hash(tabId)
         }
       })
     }
-  }))
-
-  App.directive('bookmarkableTab', $location => ({
-    restrict: 'A',
-    require: 'tab',
-    link(scope, el, attrs, tab) {
-      const tabScope = el.isolateScope()
-      const tabId = attrs.bookmarkableTab
-      if (tabScope && tabId && tabId !== '') {
-        tabScope.bookmarkableTabId = tabId
-        tabScope.$watch('active', function(isActive, wasActive) {
-          if (isActive && !wasActive && $location.hash() !== tabId) {
-            return $location.hash(tabId)
-          }
-        })
-      }
-    }
-  }))
-})
+  }
+}))

+ 74 - 74
services/web/frontend/js/directives/complexPassword.js

@@ -3,88 +3,88 @@
 /* eslint-disable
     max-len
 */
-define(['../base', 'libs/passfield'], function(App) {
-  App.directive('complexPassword', () => ({
-    require: ['^asyncForm', 'ngModel'],
+import App from '../base'
+import 'libs/passfield'
+App.directive('complexPassword', () => ({
+  require: ['^asyncForm', 'ngModel'],
 
-    link(scope, element, attrs, ctrl) {
-      PassField.Config.blackList = []
-      const defaultPasswordOpts = {
-        pattern: '',
-        length: {
-          min: 6,
-          max: 72
-        },
-        allowEmpty: false,
-        allowAnyChars: false,
-        isMasked: true,
-        showToggle: false,
-        showGenerate: false,
-        showTip: false,
-        showWarn: false,
-        checkMode: PassField.CheckModes.STRICT,
-        chars: {
-          digits: '1234567890',
-          letters: 'abcdefghijklmnopqrstuvwxyz',
-          letters_up: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
-          symbols: '@#$%^&*()-_=+[]{};:<>/?!£€.,'
-        }
+  link(scope, element, attrs, ctrl) {
+    PassField.Config.blackList = []
+    const defaultPasswordOpts = {
+      pattern: '',
+      length: {
+        min: 6,
+        max: 72
+      },
+      allowEmpty: false,
+      allowAnyChars: false,
+      isMasked: true,
+      showToggle: false,
+      showGenerate: false,
+      showTip: false,
+      showWarn: false,
+      checkMode: PassField.CheckModes.STRICT,
+      chars: {
+        digits: '1234567890',
+        letters: 'abcdefghijklmnopqrstuvwxyz',
+        letters_up: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
+        symbols: '@#$%^&*()-_=+[]{};:<>/?!£€.,'
       }
+    }
 
-      const opts = _.defaults(
-        window.passwordStrengthOptions || {},
-        defaultPasswordOpts
-      )
+    const opts = _.defaults(
+      window.passwordStrengthOptions || {},
+      defaultPasswordOpts
+    )
 
-      if (opts.length.min === 1) {
-        // this allows basically anything to be a valid password
-        opts.acceptRate = 0
-      }
+    if (opts.length.min === 1) {
+      // this allows basically anything to be a valid password
+      opts.acceptRate = 0
+    }
 
-      if (opts.length.max > 72) {
-        // there is a hard limit of 71 characters in the password at the backend
-        opts.length.max = 72
-      }
+    if (opts.length.max > 72) {
+      // there is a hard limit of 71 characters in the password at the backend
+      opts.length.max = 72
+    }
 
-      if (opts.length.max > 0) {
-        // PassField's notion of 'max' is non-inclusive
-        opts.length.max += 1
-      }
+    if (opts.length.max > 0) {
+      // PassField's notion of 'max' is non-inclusive
+      opts.length.max += 1
+    }
 
-      const passField = new PassField.Field('passwordField', opts)
-      const [asyncFormCtrl, ngModelCtrl] = Array.from(ctrl)
+    const passField = new PassField.Field('passwordField', opts)
+    const [asyncFormCtrl, ngModelCtrl] = Array.from(ctrl)
 
-      ngModelCtrl.$parsers.unshift(function(modelValue) {
-        let isValid = passField.validatePass()
-        const email = asyncFormCtrl.getEmail() || window.usersEmail
+    ngModelCtrl.$parsers.unshift(function(modelValue) {
+      let isValid = passField.validatePass()
+      const email = asyncFormCtrl.getEmail() || window.usersEmail
 
-        if (!isValid) {
-          scope.complexPasswordErrorMessage = passField.getPassValidationMessage()
-        } else if (typeof email === 'string' && email !== '') {
-          const startOfEmail = email.split('@')[0]
-          if (
-            modelValue.indexOf(email) !== -1 ||
-            modelValue.indexOf(startOfEmail) !== -1
-          ) {
-            isValid = false
-            scope.complexPasswordErrorMessage =
-              'Password can not contain email address'
-          }
-        }
-        if (opts.length.max != null && modelValue.length >= opts.length.max) {
-          isValid = false
-          scope.complexPasswordErrorMessage = `Maximum password length ${opts
-            .length.max - 1} exceeded`
-        }
-        if (opts.length.min != null && modelValue.length < opts.length.min) {
+      if (!isValid) {
+        scope.complexPasswordErrorMessage = passField.getPassValidationMessage()
+      } else if (typeof email === 'string' && email !== '') {
+        const startOfEmail = email.split('@')[0]
+        if (
+          modelValue.indexOf(email) !== -1 ||
+          modelValue.indexOf(startOfEmail) !== -1
+        ) {
           isValid = false
-          scope.complexPasswordErrorMessage = `Password too short, minimum ${
-            opts.length.min
-          }`
+          scope.complexPasswordErrorMessage =
+            'Password can not contain email address'
         }
-        ngModelCtrl.$setValidity('complexPassword', isValid)
-        return modelValue
-      })
-    }
-  }))
-})
+      }
+      if (opts.length.max != null && modelValue.length >= opts.length.max) {
+        isValid = false
+        scope.complexPasswordErrorMessage = `Maximum password length ${opts
+          .length.max - 1} exceeded`
+      }
+      if (opts.length.min != null && modelValue.length < opts.length.min) {
+        isValid = false
+        scope.complexPasswordErrorMessage = `Password too short, minimum ${
+          opts.length.min
+        }`
+      }
+      ngModelCtrl.$setValidity('complexPassword', isValid)
+      return modelValue
+    })
+  }
+}))

+ 601 - 605
services/web/frontend/js/directives/creditCards.js

@@ -12,705 +12,701 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], function(App) {
-  const defaultFormat = /(\d{1,4})/g
-  const defaultInputFormat = /(?:^|\s)(\d{4})$/
-
-  const cards = [
-    // Credit cards
-    {
-      type: 'visa',
-      patterns: [4],
-      format: defaultFormat,
-      length: [13, 16],
-      cvcLength: [3],
-      luhn: true
-    },
-    {
-      type: 'mastercard',
-      patterns: [51, 52, 53, 54, 55, 22, 23, 24, 25, 26, 27],
-      format: defaultFormat,
-      length: [16],
-      cvcLength: [3],
-      luhn: true
-    },
-    {
-      type: 'amex',
-      patterns: [34, 37],
-      format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/,
-      length: [15],
-      cvcLength: [3, 4],
-      luhn: true
-    },
-    {
-      type: 'dinersclub',
-      patterns: [30, 36, 38, 39],
-      format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/,
-      length: [14],
-      cvcLength: [3],
-      luhn: true
-    },
-    {
-      type: 'discover',
-      patterns: [60, 64, 65, 622],
-      format: defaultFormat,
-      length: [16],
-      cvcLength: [3],
-      luhn: true
-    },
-    {
-      type: 'unionpay',
-      patterns: [62, 88],
-      format: defaultFormat,
-      length: [16, 17, 18, 19],
-      cvcLength: [3],
-      luhn: false
-    },
-    {
-      type: 'jcb',
-      patterns: [35],
-      format: defaultFormat,
-      length: [16],
-      cvcLength: [3],
-      luhn: true
-    }
-  ]
-
-  App.factory('ccUtils', function() {
-    const cardFromNumber = function(num) {
-      num = (num + '').replace(/\D/g, '')
-      for (let card of Array.from(cards)) {
-        for (let pattern of Array.from(card.patterns)) {
-          const p = pattern + ''
-          if (num.substr(0, p.length) === p) {
-            return card
-          }
-        }
-      }
-    }
-
-    const cardFromType = function(type) {
-      for (let card of Array.from(cards)) {
-        if (card.type === type) {
+import App from '../base'
+const defaultFormat = /(\d{1,4})/g
+const defaultInputFormat = /(?:^|\s)(\d{4})$/
+
+const cards = [
+  // Credit cards
+  {
+    type: 'visa',
+    patterns: [4],
+    format: defaultFormat,
+    length: [13, 16],
+    cvcLength: [3],
+    luhn: true
+  },
+  {
+    type: 'mastercard',
+    patterns: [51, 52, 53, 54, 55, 22, 23, 24, 25, 26, 27],
+    format: defaultFormat,
+    length: [16],
+    cvcLength: [3],
+    luhn: true
+  },
+  {
+    type: 'amex',
+    patterns: [34, 37],
+    format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/,
+    length: [15],
+    cvcLength: [3, 4],
+    luhn: true
+  },
+  {
+    type: 'dinersclub',
+    patterns: [30, 36, 38, 39],
+    format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/,
+    length: [14],
+    cvcLength: [3],
+    luhn: true
+  },
+  {
+    type: 'discover',
+    patterns: [60, 64, 65, 622],
+    format: defaultFormat,
+    length: [16],
+    cvcLength: [3],
+    luhn: true
+  },
+  {
+    type: 'unionpay',
+    patterns: [62, 88],
+    format: defaultFormat,
+    length: [16, 17, 18, 19],
+    cvcLength: [3],
+    luhn: false
+  },
+  {
+    type: 'jcb',
+    patterns: [35],
+    format: defaultFormat,
+    length: [16],
+    cvcLength: [3],
+    luhn: true
+  }
+]
+
+App.factory('ccUtils', function() {
+  const cardFromNumber = function(num) {
+    num = (num + '').replace(/\D/g, '')
+    for (let card of Array.from(cards)) {
+      for (let pattern of Array.from(card.patterns)) {
+        const p = pattern + ''
+        if (num.substr(0, p.length) === p) {
           return card
         }
       }
     }
+  }
 
-    const cardType = function(num) {
-      if (!num) {
-        return null
+  const cardFromType = function(type) {
+    for (let card of Array.from(cards)) {
+      if (card.type === type) {
+        return card
       }
-      return __guard__(cardFromNumber(num), x => x.type) || null
     }
+  }
 
-    const formatCardNumber = function(num) {
-      num = num.replace(/\D/g, '')
-      const card = cardFromNumber(num)
-      if (!card) {
-        return num
-      }
-
-      const upperLength = card.length[card.length.length - 1]
-      num = num.slice(0, upperLength)
-
-      if (card.format.global) {
-        return __guard__(num.match(card.format), x => x.join(' '))
-      } else {
-        let groups = card.format.exec(num)
-        if (groups == null) {
-          return
-        }
-        groups.shift()
-        groups = $.grep(groups, n => n) // Filter empty groups
-        return groups.join(' ')
-      }
+  const cardType = function(num) {
+    if (!num) {
+      return null
     }
+    return __guard__(cardFromNumber(num), x => x.type) || null
+  }
 
-    const formatExpiry = function(expiry) {
-      const parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/)
-      if (!parts) {
-        return ''
-      }
-
-      let mon = parts[1] || ''
-      let sep = parts[2] || ''
-      const year = parts[3] || ''
-
-      if (year.length > 0) {
-        sep = ' / '
-      } else if (sep === ' /') {
-        mon = mon.substring(0, 1)
-        sep = ''
-      } else if (mon.length === 2 || sep.length > 0) {
-        sep = ' / '
-      } else if (mon.length === 1 && !['0', '1'].includes(mon)) {
-        mon = `0${mon}`
-        sep = ' / '
-      }
-
-      return mon + sep + year
+  const formatCardNumber = function(num) {
+    num = num.replace(/\D/g, '')
+    const card = cardFromNumber(num)
+    if (!card) {
+      return num
     }
 
-    const parseExpiry = function(value) {
-      if (value == null) {
-        value = ''
-      }
-      let [month, year] = Array.from(value.split(/[\s\/]+/, 2))
-
-      // Allow for year shortcut
-      if (
-        (year != null ? year.length : undefined) === 2 &&
-        /^\d+$/.test(year)
-      ) {
-        let prefix = new Date().getFullYear()
-        prefix = prefix.toString().slice(0, 2)
-        year = prefix + year
-      }
+    const upperLength = card.length[card.length.length - 1]
+    num = num.slice(0, upperLength)
 
-      month = parseInt(month, 10)
-      year = parseInt(year, 10)
-
-      if (!!isNaN(month) || !!isNaN(year)) {
+    if (card.format.global) {
+      return __guard__(num.match(card.format), x => x.join(' '))
+    } else {
+      let groups = card.format.exec(num)
+      if (groups == null) {
         return
       }
-
-      return { month, year }
+      groups.shift()
+      groups = $.grep(groups, n => n) // Filter empty groups
+      return groups.join(' ')
     }
+  }
 
-    return {
-      fromNumber: cardFromNumber,
-      fromType: cardFromType,
-      cardType,
-      formatExpiry,
-      formatCardNumber,
-      defaultFormat,
-      defaultInputFormat,
-      parseExpiry
+  const formatExpiry = function(expiry) {
+    const parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/)
+    if (!parts) {
+      return ''
     }
-  })
 
-  App.factory('ccFormat', function(ccUtils, $filter) {
-    const hasTextSelected = function($target) {
-      // If some text is selected
-      if (
-        $target.prop('selectionStart') != null &&
-        $target.prop('selectionStart') !== $target.prop('selectionEnd')
-      ) {
-        return true
-      }
-
-      // If some text is selected in IE
-      if (
-        __guard__(
-          typeof document !== 'undefined' && document !== null
-            ? document.selection
-            : undefined,
-          x => x.createRange
-        ) != null
-      ) {
-        if (document.selection.createRange().text) {
-          return true
-        }
-      }
+    let mon = parts[1] || ''
+    let sep = parts[2] || ''
+    const year = parts[3] || ''
 
-      return false
+    if (year.length > 0) {
+      sep = ' / '
+    } else if (sep === ' /') {
+      mon = mon.substring(0, 1)
+      sep = ''
+    } else if (mon.length === 2 || sep.length > 0) {
+      sep = ' / '
+    } else if (mon.length === 1 && !['0', '1'].includes(mon)) {
+      mon = `0${mon}`
+      sep = ' / '
     }
 
-    const safeVal = function(value, $target) {
-      let cursor
-      try {
-        cursor = $target.prop('selectionStart')
-      } catch (error) {
-        cursor = null
-      }
-
-      const last = $target.val()
-      $target.val(value)
+    return mon + sep + year
+  }
 
-      if (cursor !== null && $target.is(':focus')) {
-        if (cursor === last.length) {
-          cursor = value.length
-        }
-
-        // This hack looks for scenarios where we are changing an input's value such
-        // that "X| " is replaced with " |X" (where "|" is the cursor). In those
-        // scenarios, we want " X|".
-        //
-        // For example:
-        // 1. Input field has value "4444| "
-        // 2. User types "1"
-        // 3. Input field has value "44441| "
-        // 4. Reformatter changes it to "4444 |1"
-        // 5. By incrementing the cursor, we make it "4444 1|"
-        //
-        // This is awful, and ideally doesn't go here, but given the current design
-        // of the system there does not appear to be a better solution.
-        //
-        // Note that we can't just detect when the cursor-1 is " ", because that
-        // would incorrectly increment the cursor when backspacing, e.g. pressing
-        // backspace in this scenario: "4444 1|234 5".
-        if (last !== value) {
-          const prevPair = last.slice(cursor - 1, +cursor + 1 || undefined)
-          const currPair = value.slice(cursor - 1, +cursor + 1 || undefined)
-          const digit = value[cursor]
-          if (
-            /\d/.test(digit) &&
-            prevPair === `${digit} ` &&
-            currPair === ` ${digit}`
-          ) {
-            cursor = cursor + 1
-          }
-        }
-
-        $target.prop('selectionStart', cursor)
-        return $target.prop('selectionEnd', cursor)
-      }
+  const parseExpiry = function(value) {
+    if (value == null) {
+      value = ''
     }
+    let [month, year] = Array.from(value.split(/[\s\/]+/, 2))
 
-    // Replace Full-Width Chars
-    const replaceFullWidthChars = function(str) {
-      if (str == null) {
-        str = ''
-      }
-      const fullWidth =
-        '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19'
-      const halfWidth = '0123456789'
-
-      let value = ''
-      const chars = str.split('')
-
-      // Avoid using reserved word `char`
-      for (let chr of Array.from(chars)) {
-        const idx = fullWidth.indexOf(chr)
-        if (idx > -1) {
-          chr = halfWidth[idx]
-        }
-        value += chr
-      }
-
-      return value
+    // Allow for year shortcut
+    if ((year != null ? year.length : undefined) === 2 && /^\d+$/.test(year)) {
+      let prefix = new Date().getFullYear()
+      prefix = prefix.toString().slice(0, 2)
+      year = prefix + year
     }
 
-    // Format Numeric
-    const reFormatNumeric = function(e) {
-      const $target = $(e.currentTarget)
-      return setTimeout(function() {
-        let value = $target.val()
-        value = replaceFullWidthChars(value)
-        value = value.replace(/\D/g, '')
-        return safeVal(value, $target)
-      })
-    }
+    month = parseInt(month, 10)
+    year = parseInt(year, 10)
 
-    // Format Card Number
-    const reFormatCardNumber = function(e) {
-      const $target = $(e.currentTarget)
-      return setTimeout(function() {
-        let value = $target.val()
-        value = replaceFullWidthChars(value)
-        value = ccUtils.formatCardNumber(value)
-        return safeVal(value, $target)
-      })
+    if (!!isNaN(month) || !!isNaN(year)) {
+      return
     }
 
-    const formatCardNumber = function(e) {
-      // Only format if input is a number
-      let re
-      const digit = String.fromCharCode(e.which)
-      if (!/^\d+$/.test(digit)) {
-        return
-      }
-
-      const $target = $(e.currentTarget)
-      const value = $target.val()
-      const card = ccUtils.fromNumber(value + digit)
-      const { length } = value.replace(/\D/g, '') + digit
+    return { month, year }
+  }
 
-      let upperLength = 16
-      if (card) {
-        upperLength = card.length[card.length.length - 1]
-      }
-      if (length >= upperLength) {
-        return
-      }
+  return {
+    fromNumber: cardFromNumber,
+    fromType: cardFromType,
+    cardType,
+    formatExpiry,
+    formatCardNumber,
+    defaultFormat,
+    defaultInputFormat,
+    parseExpiry
+  }
+})
 
-      // Return if focus isn't at the end of the text
-      if (
-        $target.prop('selectionStart') != null &&
-        $target.prop('selectionStart') !== value.length
-      ) {
-        return
+App.factory('ccFormat', function(ccUtils, $filter) {
+  const hasTextSelected = function($target) {
+    // If some text is selected
+    if (
+      $target.prop('selectionStart') != null &&
+      $target.prop('selectionStart') !== $target.prop('selectionEnd')
+    ) {
+      return true
+    }
+
+    // If some text is selected in IE
+    if (
+      __guard__(
+        typeof document !== 'undefined' && document !== null
+          ? document.selection
+          : undefined,
+        x => x.createRange
+      ) != null
+    ) {
+      if (document.selection.createRange().text) {
+        return true
       }
+    }
 
-      if (card && card.type === 'amex') {
-        // AMEX cards are formatted differently
-        re = /^(\d{4}|\d{4}\s\d{6})$/
-      } else {
-        re = /(?:^|\s)(\d{4})$/
+    return false
+  }
+
+  const safeVal = function(value, $target) {
+    let cursor
+    try {
+      cursor = $target.prop('selectionStart')
+    } catch (error) {
+      cursor = null
+    }
+
+    const last = $target.val()
+    $target.val(value)
+
+    if (cursor !== null && $target.is(':focus')) {
+      if (cursor === last.length) {
+        cursor = value.length
+      }
+
+      // This hack looks for scenarios where we are changing an input's value such
+      // that "X| " is replaced with " |X" (where "|" is the cursor). In those
+      // scenarios, we want " X|".
+      //
+      // For example:
+      // 1. Input field has value "4444| "
+      // 2. User types "1"
+      // 3. Input field has value "44441| "
+      // 4. Reformatter changes it to "4444 |1"
+      // 5. By incrementing the cursor, we make it "4444 1|"
+      //
+      // This is awful, and ideally doesn't go here, but given the current design
+      // of the system there does not appear to be a better solution.
+      //
+      // Note that we can't just detect when the cursor-1 is " ", because that
+      // would incorrectly increment the cursor when backspacing, e.g. pressing
+      // backspace in this scenario: "4444 1|234 5".
+      if (last !== value) {
+        const prevPair = last.slice(cursor - 1, +cursor + 1 || undefined)
+        const currPair = value.slice(cursor - 1, +cursor + 1 || undefined)
+        const digit = value[cursor]
+        if (
+          /\d/.test(digit) &&
+          prevPair === `${digit} ` &&
+          currPair === ` ${digit}`
+        ) {
+          cursor = cursor + 1
+        }
       }
 
-      // If '4242' + 4
-      if (re.test(value)) {
-        e.preventDefault()
-        return setTimeout(() => $target.val(value + ' ' + digit))
-
-        // If '424' + 2
-      } else if (re.test(value + digit)) {
-        e.preventDefault()
-        return setTimeout(() => $target.val(value + digit + ' '))
-      }
+      $target.prop('selectionStart', cursor)
+      return $target.prop('selectionEnd', cursor)
     }
+  }
 
-    const formatBackCardNumber = function(e) {
-      const $target = $(e.currentTarget)
-      const value = $target.val()
-
-      // Return unless backspacing
-      if (e.which !== 8) {
-        return
-      }
+  // Replace Full-Width Chars
+  const replaceFullWidthChars = function(str) {
+    if (str == null) {
+      str = ''
+    }
+    const fullWidth =
+      '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19'
+    const halfWidth = '0123456789'
 
-      // Return if focus isn't at the end of the text
-      if (
-        $target.prop('selectionStart') != null &&
-        $target.prop('selectionStart') !== value.length
-      ) {
-        return
-      }
+    let value = ''
+    const chars = str.split('')
 
-      // Remove the digit + trailing space
-      if (/\d\s$/.test(value)) {
-        e.preventDefault()
-        return setTimeout(() => $target.val(value.replace(/\d\s$/, '')))
-        // Remove digit if ends in space + digit
-      } else if (/\s\d?$/.test(value)) {
-        e.preventDefault()
-        return setTimeout(() => $target.val(value.replace(/\d$/, '')))
+    // Avoid using reserved word `char`
+    for (let chr of Array.from(chars)) {
+      const idx = fullWidth.indexOf(chr)
+      if (idx > -1) {
+        chr = halfWidth[idx]
       }
+      value += chr
     }
 
-    const getFormattedCardNumber = function(num) {
-      num = num.replace(/\D/g, '')
-      const card = ccUtils.fromNumber(num)
-      if (!card) {
-        return num
-      }
-
-      const upperLength = card.length[card.length.length - 1]
-      num = num.slice(0, upperLength)
+    return value
+  }
 
-      if (card.format.global) {
-        return __guard__(num.match(card.format), x => x.join(' '))
-      } else {
-        let groups = card.format.exec(num)
-        if (groups == null) {
-          return
-        }
-        groups.shift()
-        groups = $.grep(groups, n => n) // Filter empty groups
-        return groups.join(' ')
+  // Format Numeric
+  const reFormatNumeric = function(e) {
+    const $target = $(e.currentTarget)
+    return setTimeout(function() {
+      let value = $target.val()
+      value = replaceFullWidthChars(value)
+      value = value.replace(/\D/g, '')
+      return safeVal(value, $target)
+    })
+  }
+
+  // Format Card Number
+  const reFormatCardNumber = function(e) {
+    const $target = $(e.currentTarget)
+    return setTimeout(function() {
+      let value = $target.val()
+      value = replaceFullWidthChars(value)
+      value = ccUtils.formatCardNumber(value)
+      return safeVal(value, $target)
+    })
+  }
+
+  const formatCardNumber = function(e) {
+    // Only format if input is a number
+    let re
+    const digit = String.fromCharCode(e.which)
+    if (!/^\d+$/.test(digit)) {
+      return
+    }
+
+    const $target = $(e.currentTarget)
+    const value = $target.val()
+    const card = ccUtils.fromNumber(value + digit)
+    const { length } = value.replace(/\D/g, '') + digit
+
+    let upperLength = 16
+    if (card) {
+      upperLength = card.length[card.length.length - 1]
+    }
+    if (length >= upperLength) {
+      return
+    }
+
+    // Return if focus isn't at the end of the text
+    if (
+      $target.prop('selectionStart') != null &&
+      $target.prop('selectionStart') !== value.length
+    ) {
+      return
+    }
+
+    if (card && card.type === 'amex') {
+      // AMEX cards are formatted differently
+      re = /^(\d{4}|\d{4}\s\d{6})$/
+    } else {
+      re = /(?:^|\s)(\d{4})$/
+    }
+
+    // If '4242' + 4
+    if (re.test(value)) {
+      e.preventDefault()
+      return setTimeout(() => $target.val(value + ' ' + digit))
+
+      // If '424' + 2
+    } else if (re.test(value + digit)) {
+      e.preventDefault()
+      return setTimeout(() => $target.val(value + digit + ' '))
+    }
+  }
+
+  const formatBackCardNumber = function(e) {
+    const $target = $(e.currentTarget)
+    const value = $target.val()
+
+    // Return unless backspacing
+    if (e.which !== 8) {
+      return
+    }
+
+    // Return if focus isn't at the end of the text
+    if (
+      $target.prop('selectionStart') != null &&
+      $target.prop('selectionStart') !== value.length
+    ) {
+      return
+    }
+
+    // Remove the digit + trailing space
+    if (/\d\s$/.test(value)) {
+      e.preventDefault()
+      return setTimeout(() => $target.val(value.replace(/\d\s$/, '')))
+      // Remove digit if ends in space + digit
+    } else if (/\s\d?$/.test(value)) {
+      e.preventDefault()
+      return setTimeout(() => $target.val(value.replace(/\d$/, '')))
+    }
+  }
+
+  const getFormattedCardNumber = function(num) {
+    num = num.replace(/\D/g, '')
+    const card = ccUtils.fromNumber(num)
+    if (!card) {
+      return num
+    }
+
+    const upperLength = card.length[card.length.length - 1]
+    num = num.slice(0, upperLength)
+
+    if (card.format.global) {
+      return __guard__(num.match(card.format), x => x.join(' '))
+    } else {
+      let groups = card.format.exec(num)
+      if (groups == null) {
+        return
       }
+      groups.shift()
+      groups = $.grep(groups, n => n) // Filter empty groups
+      return groups.join(' ')
     }
+  }
 
-    const parseCardNumber = function(value) {
-      if (value != null) {
-        return value.replace(/\s/g, '')
-      } else {
-        return value
-      }
+  const parseCardNumber = function(value) {
+    if (value != null) {
+      return value.replace(/\s/g, '')
+    } else {
+      return value
     }
-
-    // Format Expiry
-    const reFormatExpiry = function(e) {
-      const $target = $(e.currentTarget)
+  }
+
+  // Format Expiry
+  const reFormatExpiry = function(e) {
+    const $target = $(e.currentTarget)
+    return setTimeout(function() {
+      let value = $target.val()
+      value = replaceFullWidthChars(value)
+      value = ccUtils.formatExpiry(value)
+      return safeVal(value, $target)
+    })
+  }
+
+  const formatExpiry = function(e) {
+    // Only format if input is a number
+    const digit = String.fromCharCode(e.which)
+    if (!/^\d+$/.test(digit)) {
+      return
+    }
+
+    const $target = $(e.currentTarget)
+    const val = $target.val() + digit
+
+    if (/^\d$/.test(val) && !['0', '1'].includes(val)) {
+      e.preventDefault()
+      return setTimeout(() => $target.val(`0${val} / `))
+    } else if (/^\d\d$/.test(val)) {
+      e.preventDefault()
       return setTimeout(function() {
-        let value = $target.val()
-        value = replaceFullWidthChars(value)
-        value = ccUtils.formatExpiry(value)
-        return safeVal(value, $target)
+        // Split for months where we have the second digit > 2 (past 12) and turn
+        // that into (m1)(m2) => 0(m1) / (m2)
+        const m1 = parseInt(val[0], 10)
+        const m2 = parseInt(val[1], 10)
+        if (m2 > 2 && m1 !== 0) {
+          return $target.val(`0${m1} / ${m2}`)
+        } else {
+          return $target.val(`${val} / `)
+        }
       })
     }
+  }
 
-    const formatExpiry = function(e) {
-      // Only format if input is a number
-      const digit = String.fromCharCode(e.which)
-      if (!/^\d+$/.test(digit)) {
-        return
-      }
-
-      const $target = $(e.currentTarget)
-      const val = $target.val() + digit
-
-      if (/^\d$/.test(val) && !['0', '1'].includes(val)) {
-        e.preventDefault()
-        return setTimeout(() => $target.val(`0${val} / `))
-      } else if (/^\d\d$/.test(val)) {
-        e.preventDefault()
-        return setTimeout(function() {
-          // Split for months where we have the second digit > 2 (past 12) and turn
-          // that into (m1)(m2) => 0(m1) / (m2)
-          const m1 = parseInt(val[0], 10)
-          const m2 = parseInt(val[1], 10)
-          if (m2 > 2 && m1 !== 0) {
-            return $target.val(`0${m1} / ${m2}`)
-          } else {
-            return $target.val(`${val} / `)
-          }
-        })
-      }
+  const formatForwardExpiry = function(e) {
+    const digit = String.fromCharCode(e.which)
+    if (!/^\d+$/.test(digit)) {
+      return
     }
 
-    const formatForwardExpiry = function(e) {
-      const digit = String.fromCharCode(e.which)
-      if (!/^\d+$/.test(digit)) {
-        return
-      }
-
-      const $target = $(e.currentTarget)
-      const val = $target.val()
+    const $target = $(e.currentTarget)
+    const val = $target.val()
 
-      if (/^\d\d$/.test(val)) {
-        return $target.val(`${val} / `)
-      }
+    if (/^\d\d$/.test(val)) {
+      return $target.val(`${val} / `)
     }
+  }
 
-    const formatForwardSlash = function(e) {
-      const which = String.fromCharCode(e.which)
-      if (which !== '/' && which !== ' ') {
-        return
-      }
-
-      const $target = $(e.currentTarget)
-      const val = $target.val()
-
-      if (/^\d$/.test(val) && val !== '0') {
-        return $target.val(`0${val} / `)
-      }
+  const formatForwardSlash = function(e) {
+    const which = String.fromCharCode(e.which)
+    if (which !== '/' && which !== ' ') {
+      return
     }
 
-    const formatBackExpiry = function(e) {
-      const $target = $(e.currentTarget)
-      const value = $target.val()
+    const $target = $(e.currentTarget)
+    const val = $target.val()
 
-      // Return unless backspacing
-      if (e.which !== 8) {
-        return
-      }
-
-      // Return if focus isn't at the end of the text
-      if (
-        $target.prop('selectionStart') != null &&
-        $target.prop('selectionStart') !== value.length
-      ) {
-        return
-      }
-
-      // Remove the trailing space + last digit
-      if (/\d\s\/\s$/.test(value)) {
-        e.preventDefault()
-        return setTimeout(() => $target.val(value.replace(/\d\s\/\s$/, '')))
-      }
+    if (/^\d$/.test(val) && val !== '0') {
+      return $target.val(`0${val} / `)
     }
+  }
 
-    const parseExpiry = function(value) {
-      if (value != null) {
-        const dateAsObj = ccUtils.parseExpiry(value)
-
-        if (dateAsObj == null) {
-          return
-        }
-
-        const expiry = new Date(dateAsObj.year, dateAsObj.month - 1)
+  const formatBackExpiry = function(e) {
+    const $target = $(e.currentTarget)
+    const value = $target.val()
 
-        return $filter('date')(expiry, 'MM/yyyy')
-      }
+    // Return unless backspacing
+    if (e.which !== 8) {
+      return
     }
 
-    // Format CVC
-    const reFormatCVC = function(e) {
-      const $target = $(e.currentTarget)
-      return setTimeout(function() {
-        let value = $target.val()
-        value = replaceFullWidthChars(value)
-        value = value.replace(/\D/g, '').slice(0, 4)
-        return safeVal(value, $target)
-      })
+    // Return if focus isn't at the end of the text
+    if (
+      $target.prop('selectionStart') != null &&
+      $target.prop('selectionStart') !== value.length
+    ) {
+      return
     }
 
-    // Restrictions
-    const restrictNumeric = function(e) {
-      // Key event is for a browser shortcut
-      if (e.metaKey || e.ctrlKey) {
-        return true
-      }
-
-      // If keycode is a space
-      if (e.which === 32) {
-        return false
-      }
+    // Remove the trailing space + last digit
+    if (/\d\s\/\s$/.test(value)) {
+      e.preventDefault()
+      return setTimeout(() => $target.val(value.replace(/\d\s\/\s$/, '')))
+    }
+  }
 
-      // If keycode is a special char (WebKit)
-      if (e.which === 0) {
-        return true
-      }
+  const parseExpiry = function(value) {
+    if (value != null) {
+      const dateAsObj = ccUtils.parseExpiry(value)
 
-      // If char is a special char (Firefox)
-      if (e.which < 33) {
-        return true
+      if (dateAsObj == null) {
+        return
       }
 
-      const input = String.fromCharCode(e.which)
+      const expiry = new Date(dateAsObj.year, dateAsObj.month - 1)
 
-      // Char is a number or a space
-      return !!/[\d\s]/.test(input)
+      return $filter('date')(expiry, 'MM/yyyy')
     }
+  }
 
-    const restrictCardNumber = function(e) {
-      const $target = $(e.currentTarget)
-      const digit = String.fromCharCode(e.which)
-      if (!/^\d+$/.test(digit)) {
-        return
-      }
+  // Format CVC
+  const reFormatCVC = function(e) {
+    const $target = $(e.currentTarget)
+    return setTimeout(function() {
+      let value = $target.val()
+      value = replaceFullWidthChars(value)
+      value = value.replace(/\D/g, '').slice(0, 4)
+      return safeVal(value, $target)
+    })
+  }
 
-      if (hasTextSelected($target)) {
-        return
-      }
+  // Restrictions
+  const restrictNumeric = function(e) {
+    // Key event is for a browser shortcut
+    if (e.metaKey || e.ctrlKey) {
+      return true
+    }
 
-      // Restrict number of digits
-      const value = ($target.val() + digit).replace(/\D/g, '')
-      const card = ccUtils.fromNumber(value)
+    // If keycode is a space
+    if (e.which === 32) {
+      return false
+    }
 
-      if (card) {
-        return value.length <= card.length[card.length.length - 1]
-      } else {
-        // All other cards are 16 digits long
-        return value.length <= 16
-      }
+    // If keycode is a special char (WebKit)
+    if (e.which === 0) {
+      return true
     }
 
-    const restrictExpiry = function(e) {
-      const $target = $(e.currentTarget)
-      const digit = String.fromCharCode(e.which)
-      if (!/^\d+$/.test(digit)) {
-        return
-      }
+    // If char is a special char (Firefox)
+    if (e.which < 33) {
+      return true
+    }
 
-      if (hasTextSelected($target)) {
-        return
-      }
+    const input = String.fromCharCode(e.which)
 
-      let value = $target.val() + digit
-      value = value.replace(/\D/g, '')
+    // Char is a number or a space
+    return !!/[\d\s]/.test(input)
+  }
 
-      if (value.length > 6) {
-        return false
-      }
+  const restrictCardNumber = function(e) {
+    const $target = $(e.currentTarget)
+    const digit = String.fromCharCode(e.which)
+    if (!/^\d+$/.test(digit)) {
+      return
     }
 
-    const restrictCVC = function(e) {
-      const $target = $(e.currentTarget)
-      const digit = String.fromCharCode(e.which)
-      if (!/^\d+$/.test(digit)) {
-        return
-      }
+    if (hasTextSelected($target)) {
+      return
+    }
 
-      if (hasTextSelected($target)) {
-        return
-      }
+    // Restrict number of digits
+    const value = ($target.val() + digit).replace(/\D/g, '')
+    const card = ccUtils.fromNumber(value)
 
-      const val = $target.val() + digit
-      return val.length <= 4
+    if (card) {
+      return value.length <= card.length[card.length.length - 1]
+    } else {
+      // All other cards are 16 digits long
+      return value.length <= 16
     }
+  }
 
-    const setCardType = function(e) {
-      const $target = $(e.currentTarget)
-      const val = $target.val()
-      const cardType = ccUtils.cardType(val) || 'unknown'
+  const restrictExpiry = function(e) {
+    const $target = $(e.currentTarget)
+    const digit = String.fromCharCode(e.which)
+    if (!/^\d+$/.test(digit)) {
+      return
+    }
 
-      if (!$target.hasClass(cardType)) {
-        const allTypes = Array.from(cards).map(card => card.type)
+    if (hasTextSelected($target)) {
+      return
+    }
 
-        $target.removeClass('unknown')
-        $target.removeClass(allTypes.join(' '))
+    let value = $target.val() + digit
+    value = value.replace(/\D/g, '')
 
-        $target.addClass(cardType)
-        $target.toggleClass('identified', cardType !== 'unknown')
-        return $target.trigger('payment.cardType', cardType)
-      }
+    if (value.length > 6) {
+      return false
     }
-
-    return {
-      hasTextSelected,
-      replaceFullWidthChars,
-      reFormatNumeric,
-      reFormatCardNumber,
-      formatCardNumber,
-      formatBackCardNumber,
-      getFormattedCardNumber,
-      parseCardNumber,
-      reFormatExpiry,
-      formatExpiry,
-      formatForwardExpiry,
-      formatForwardSlash,
-      formatBackExpiry,
-      parseExpiry,
-      reFormatCVC,
-      restrictNumeric,
-      restrictCardNumber,
-      restrictExpiry,
-      restrictCVC,
-      setCardType
-    }
-  })
-
-  App.directive('ccFormatExpiry', ccFormat => ({
-    restrict: 'A',
-    require: 'ngModel',
-    link(scope, el, attrs, ngModel) {
-      el.on('keypress', ccFormat.restrictNumeric)
-      el.on('keypress', ccFormat.restrictExpiry)
-      el.on('keypress', ccFormat.formatExpiry)
-      el.on('keypress', ccFormat.formatForwardSlash)
-      el.on('keypress', ccFormat.formatForwardExpiry)
-      el.on('keydown', ccFormat.formatBackExpiry)
-      el.on('change', ccFormat.reFormatExpiry)
-      el.on('input', ccFormat.reFormatExpiry)
-      el.on('paste', ccFormat.reFormatExpiry)
-
-      ngModel.$parsers.push(ccFormat.parseExpiry)
-      return ngModel.$formatters.push(ccFormat.parseExpiry)
-    }
-  }))
-
-  App.directive('ccFormatCardNumber', ccFormat => ({
-    restrict: 'A',
-    require: 'ngModel',
-    link(scope, el, attrs, ngModel) {
-      el.on('keypress', ccFormat.restrictNumeric)
-      el.on('keypress', ccFormat.restrictCardNumber)
-      el.on('keypress', ccFormat.formatCardNumber)
-      el.on('keydown', ccFormat.formatBackCardNumber)
-      el.on('paste', ccFormat.reFormatCardNumber)
-
-      ngModel.$parsers.push(ccFormat.parseCardNumber)
-      return ngModel.$formatters.push(ccFormat.getFormattedCardNumber)
-    }
-  }))
-
-  return App.directive('ccFormatSecCode', ccFormat => ({
-    restrict: 'A',
-    require: 'ngModel',
-    link(scope, el, attrs, ngModel) {
-      el.on('keypress', ccFormat.restrictNumeric)
-      el.on('keypress', ccFormat.restrictCVC)
-      el.on('paste', ccFormat.reFormatCVC)
-      el.on('change', ccFormat.reFormatCVC)
-      return el.on('input', ccFormat.reFormatCVC)
-    }
-  }))
+  }
+
+  const restrictCVC = function(e) {
+    const $target = $(e.currentTarget)
+    const digit = String.fromCharCode(e.which)
+    if (!/^\d+$/.test(digit)) {
+      return
+    }
+
+    if (hasTextSelected($target)) {
+      return
+    }
+
+    const val = $target.val() + digit
+    return val.length <= 4
+  }
+
+  const setCardType = function(e) {
+    const $target = $(e.currentTarget)
+    const val = $target.val()
+    const cardType = ccUtils.cardType(val) || 'unknown'
+
+    if (!$target.hasClass(cardType)) {
+      const allTypes = Array.from(cards).map(card => card.type)
+
+      $target.removeClass('unknown')
+      $target.removeClass(allTypes.join(' '))
+
+      $target.addClass(cardType)
+      $target.toggleClass('identified', cardType !== 'unknown')
+      return $target.trigger('payment.cardType', cardType)
+    }
+  }
+
+  return {
+    hasTextSelected,
+    replaceFullWidthChars,
+    reFormatNumeric,
+    reFormatCardNumber,
+    formatCardNumber,
+    formatBackCardNumber,
+    getFormattedCardNumber,
+    parseCardNumber,
+    reFormatExpiry,
+    formatExpiry,
+    formatForwardExpiry,
+    formatForwardSlash,
+    formatBackExpiry,
+    parseExpiry,
+    reFormatCVC,
+    restrictNumeric,
+    restrictCardNumber,
+    restrictExpiry,
+    restrictCVC,
+    setCardType
+  }
 })
 
+App.directive('ccFormatExpiry', ccFormat => ({
+  restrict: 'A',
+  require: 'ngModel',
+  link(scope, el, attrs, ngModel) {
+    el.on('keypress', ccFormat.restrictNumeric)
+    el.on('keypress', ccFormat.restrictExpiry)
+    el.on('keypress', ccFormat.formatExpiry)
+    el.on('keypress', ccFormat.formatForwardSlash)
+    el.on('keypress', ccFormat.formatForwardExpiry)
+    el.on('keydown', ccFormat.formatBackExpiry)
+    el.on('change', ccFormat.reFormatExpiry)
+    el.on('input', ccFormat.reFormatExpiry)
+    el.on('paste', ccFormat.reFormatExpiry)
+
+    ngModel.$parsers.push(ccFormat.parseExpiry)
+    return ngModel.$formatters.push(ccFormat.parseExpiry)
+  }
+}))
+
+App.directive('ccFormatCardNumber', ccFormat => ({
+  restrict: 'A',
+  require: 'ngModel',
+  link(scope, el, attrs, ngModel) {
+    el.on('keypress', ccFormat.restrictNumeric)
+    el.on('keypress', ccFormat.restrictCardNumber)
+    el.on('keypress', ccFormat.formatCardNumber)
+    el.on('keydown', ccFormat.formatBackCardNumber)
+    el.on('paste', ccFormat.reFormatCardNumber)
+
+    ngModel.$parsers.push(ccFormat.parseCardNumber)
+    return ngModel.$formatters.push(ccFormat.getFormattedCardNumber)
+  }
+}))
+
+export default App.directive('ccFormatSecCode', ccFormat => ({
+  restrict: 'A',
+  require: 'ngModel',
+  link(scope, el, attrs, ngModel) {
+    el.on('keypress', ccFormat.restrictNumeric)
+    el.on('keypress', ccFormat.restrictCVC)
+    el.on('paste', ccFormat.reFormatCVC)
+    el.on('change', ccFormat.reFormatCVC)
+    return el.on('input', ccFormat.reFormatCVC)
+  }
+}))
+
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null
     ? transform(value)

+ 14 - 13
services/web/frontend/js/directives/equals.js

@@ -7,16 +7,17 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('equals', () => ({
-    require: 'ngModel',
-    link(scope, elem, attrs, ctrl) {
-      const firstField = `#${attrs.equals}`
-      return elem.add(firstField).on('keyup', () =>
-        scope.$apply(function() {
-          const equal = elem.val() === $(firstField).val()
-          return ctrl.$setValidity('areEqual', equal)
-        })
-      )
-    }
-  })))
+import App from '../base'
+
+export default App.directive('equals', () => ({
+  require: 'ngModel',
+  link(scope, elem, attrs, ctrl) {
+    const firstField = `#${attrs.equals}`
+    return elem.add(firstField).on('keyup', () =>
+      scope.$apply(function() {
+        const equal = elem.val() === $(firstField).val()
+        return ctrl.$setValidity('areEqual', equal)
+      })
+    )
+  }
+}))

+ 84 - 60
services/web/frontend/js/directives/eventTracking.js

@@ -23,6 +23,33 @@
 //   has been sent
 // event-tracking-trigger attribute is required to send event
 
+/* eslint-disable
+    camelcase,
+    max-len,
+*/
+// TODO: This file was created by bulk-decaffeinate.
+// Fix any style issues and re-enable lint.
+/*
+ * decaffeinate suggestions:
+ * DS102: Remove unnecessary code created because of implicit returns
+ * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
+ */
+// For sending event data to metabase and google analytics
+// ---
+// by default,
+//   event not sent to MB.
+//     for MB, add event-tracking-mb='true'
+//     by default, event sent to MB via sendMB
+//   event not sent to GA.
+//     for GA, add event-tracking-ga attribute, where the value is the GA category
+// Either GA or MB can use the attribute event-tracking-send-once='true' to
+// send event just once
+//   MB will use the key and GA will use the action to determine if the event
+//   has been sent
+// event-tracking-trigger attribute is required to send event
+
+import App from '../base'
+
 const isInViewport = function(element) {
   const elTop = element.offset().top
   const elBtm = elTop + element.outerHeight()
@@ -33,68 +60,65 @@ const isInViewport = function(element) {
   return elBtm > viewportTop && elTop < viewportBtm
 }
 
-define(['../base'], App =>
-  App.directive('eventTracking', eventTracking => ({
-    scope: {
-      eventTracking: '@',
-      eventSegmentation: '=?'
-    },
-    link(scope, element, attrs) {
-      const sendGA = attrs.eventTrackingGa || false
-      const sendMB = attrs.eventTrackingMb || false
-      const sendMBFunction = attrs.eventTrackingSendOnce
-        ? 'sendMBOnce'
-        : 'sendMB'
-      const sendGAFunction = attrs.eventTrackingSendOnce ? 'sendGAOnce' : 'send'
-      const segmentation = scope.eventSegmentation || {}
-      segmentation.page = window.location.pathname
+export default App.directive('eventTracking', eventTracking => ({
+  scope: {
+    eventTracking: '@',
+    eventSegmentation: '=?'
+  },
+  link(scope, element, attrs) {
+    const sendGA = attrs.eventTrackingGa || false
+    const sendMB = attrs.eventTrackingMb || false
+    const sendMBFunction = attrs.eventTrackingSendOnce ? 'sendMBOnce' : 'sendMB'
+    const sendGAFunction = attrs.eventTrackingSendOnce ? 'sendGAOnce' : 'send'
+    const segmentation = scope.eventSegmentation || {}
+    segmentation.page = window.location.pathname
 
-      const sendEvent = function(scrollEvent) {
-        /*
-						@param {boolean}		scrollEvent		Use to unbind scroll event
-					*/
-        if (sendMB) {
-          eventTracking[sendMBFunction](scope.eventTracking, segmentation)
-        }
-        if (sendGA) {
-          eventTracking[sendGAFunction](
-            attrs.eventTrackingGa,
-            attrs.eventTrackingAction || scope.eventTracking,
-            attrs.eventTrackingLabel || ''
-          )
-        }
-        if (scrollEvent) {
-          return $(window).unbind('resize scroll')
-        }
+    const sendEvent = function(scrollEvent) {
+      /*
+                      @param {boolean}		scrollEvent		Use to unbind scroll event
+                  */
+      if (sendMB) {
+        eventTracking[sendMBFunction](scope.eventTracking, segmentation)
       }
-
-      if (attrs.eventTrackingTrigger === 'load') {
-        return sendEvent()
-      } else if (attrs.eventTrackingTrigger === 'click') {
-        return element.on('click', e => sendEvent())
-      } else if (attrs.eventTrackingTrigger === 'hover') {
-        let timer = null
-        let timeoutAmt = 500
-        if (attrs.eventHoverAmt) {
-          timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
-        }
-        return element
-          .on('mouseenter', function() {
-            timer = setTimeout(() => sendEvent(), timeoutAmt)
-          })
-          .on('mouseleave', () => clearTimeout(timer))
-      } else if (
-        attrs.eventTrackingTrigger === 'scroll' &&
-        !eventTracking.eventInCache(scope.eventTracking)
-      ) {
-        $(window).on(
-          'resize scroll',
-          _.throttle(() => {
-            if (isInViewport(element)) {
-              sendEvent(true)
-            }
-          }, 500)
+      if (sendGA) {
+        eventTracking[sendGAFunction](
+          attrs.eventTrackingGa,
+          attrs.eventTrackingAction || scope.eventTracking,
+          attrs.eventTrackingLabel || ''
         )
       }
+      if (scrollEvent) {
+        return $(window).unbind('resize scroll')
+      }
+    }
+
+    if (attrs.eventTrackingTrigger === 'load') {
+      return sendEvent()
+    } else if (attrs.eventTrackingTrigger === 'click') {
+      return element.on('click', e => sendEvent())
+    } else if (attrs.eventTrackingTrigger === 'hover') {
+      let timer = null
+      let timeoutAmt = 500
+      if (attrs.eventHoverAmt) {
+        timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
+      }
+      return element
+        .on('mouseenter', function() {
+          timer = setTimeout(() => sendEvent(), timeoutAmt)
+        })
+        .on('mouseleave', () => clearTimeout(timer))
+    } else if (
+      attrs.eventTrackingTrigger === 'scroll' &&
+      !eventTracking.eventInCache(scope.eventTracking)
+    ) {
+      $(window).on(
+        'resize scroll',
+        _.throttle(() => {
+          if (isInViewport(element)) {
+            sendEvent(true)
+          }
+        }, 500)
+      )
     }
-  })))
+  }
+}))

+ 20 - 19
services/web/frontend/js/directives/expandableTextArea.js

@@ -7,24 +7,25 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('expandableTextArea', () => ({
-    restrict: 'A',
-    link(scope, el) {
-      const resetHeight = function() {
-        const curHeight = el.outerHeight()
-        const fitHeight = el.prop('scrollHeight')
-        // clear height if text area is empty
-        if (el.val() === '') {
-          el.css('height', 'unset')
-        }
-        // otherwise expand to fit text
-        else if (fitHeight > curHeight) {
-          scope.$emit('expandable-text-area:resize')
-          el.css('height', fitHeight)
-        }
-      }
+import App from '../base'
 
-      return scope.$watch(() => el.val(), resetHeight)
+export default App.directive('expandableTextArea', () => ({
+  restrict: 'A',
+  link(scope, el) {
+    const resetHeight = function() {
+      const curHeight = el.outerHeight()
+      const fitHeight = el.prop('scrollHeight')
+      // clear height if text area is empty
+      if (el.val() === '') {
+        el.css('height', 'unset')
+      }
+      // otherwise expand to fit text
+      else if (fitHeight > curHeight) {
+        scope.$emit('expandable-text-area:resize')
+        el.css('height', fitHeight)
+      }
     }
-  })))
+
+    return scope.$watch(() => el.val(), resetHeight)
+  }
+}))

+ 79 - 77
services/web/frontend/js/directives/fineUpload.js

@@ -8,83 +8,85 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base', 'fineuploader'], (App, qq) =>
-  App.directive('fineUpload', $timeout => ({
-    scope: {
-      multiple: '=',
-      endpoint: '@',
-      templateId: '@',
-      sizeLimit: '@',
-      allowedExtensions: '=',
-      onCompleteCallback: '=',
-      onUploadCallback: '=',
-      onValidateBatch: '=',
-      onErrorCallback: '=',
-      onSubmitCallback: '=',
-      onCancelCallback: '=',
-      autoUpload: '=',
-      params: '=',
-      control: '='
-    },
-    link(scope, element, attrs) {
-      let autoUpload, validation
-      const multiple = scope.multiple || false
-      const { endpoint } = scope
-      const { templateId } = scope
-      if (scope.allowedExtensions != null) {
-        validation = { allowedExtensions: scope.allowedExtensions }
-      } else {
-        validation = {}
-      }
-      if (scope.sizeLimit) {
-        validation.sizeLimit = scope.sizeLimit
-      }
-      const maxConnections = scope.maxConnections || 1
-      const onComplete = scope.onCompleteCallback || function() {}
-      const onUpload = scope.onUploadCallback || function() {}
-      const onError = scope.onErrorCallback || function() {}
-      const onValidateBatch = scope.onValidateBatch || function() {}
-      const onSubmit = scope.onSubmitCallback || function() {}
-      const onCancel = scope.onCancelCallback || function() {}
-      if (scope.autoUpload == null) {
-        autoUpload = true
-      } else {
-        ;({ autoUpload } = scope)
-      }
-      const params = scope.params || {}
-      params._csrf = window.csrfToken
+import App from '../base'
+import qq from 'fineuploader'
+
+export default App.directive('fineUpload', $timeout => ({
+  scope: {
+    multiple: '=',
+    endpoint: '@',
+    templateId: '@',
+    sizeLimit: '@',
+    allowedExtensions: '=',
+    onCompleteCallback: '=',
+    onUploadCallback: '=',
+    onValidateBatch: '=',
+    onErrorCallback: '=',
+    onSubmitCallback: '=',
+    onCancelCallback: '=',
+    autoUpload: '=',
+    params: '=',
+    control: '='
+  },
+  link(scope, element, attrs) {
+    let autoUpload, validation
+    const multiple = scope.multiple || false
+    const { endpoint } = scope
+    const { templateId } = scope
+    if (scope.allowedExtensions != null) {
+      validation = { allowedExtensions: scope.allowedExtensions }
+    } else {
+      validation = {}
+    }
+    if (scope.sizeLimit) {
+      validation.sizeLimit = scope.sizeLimit
+    }
+    const maxConnections = scope.maxConnections || 1
+    const onComplete = scope.onCompleteCallback || function() {}
+    const onUpload = scope.onUploadCallback || function() {}
+    const onError = scope.onErrorCallback || function() {}
+    const onValidateBatch = scope.onValidateBatch || function() {}
+    const onSubmit = scope.onSubmitCallback || function() {}
+    const onCancel = scope.onCancelCallback || function() {}
+    if (scope.autoUpload == null) {
+      autoUpload = true
+    } else {
+      ;({ autoUpload } = scope)
+    }
+    const params = scope.params || {}
+    params._csrf = window.csrfToken
 
-      const q = new qq.FineUploader({
-        element: element[0],
-        multiple,
-        autoUpload,
-        disabledCancelForFormUploads: true,
-        validation,
-        maxConnections,
-        request: {
-          endpoint,
-          forceMultipart: true,
-          params,
-          paramsInBody: false
-        },
-        callbacks: {
-          onComplete,
-          onUpload,
-          onValidateBatch,
-          onError,
-          onSubmit,
-          onCancel
-        },
-        template: templateId,
-        failedUploadTextDisplay: {
-          mode: 'custom',
-          responseProperty: 'error'
-        }
-      })
-      window.q = q
-      if (scope.control != null) {
-        scope.control.q = q
+    const q = new qq.FineUploader({
+      element: element[0],
+      multiple,
+      autoUpload,
+      disabledCancelForFormUploads: true,
+      validation,
+      maxConnections,
+      request: {
+        endpoint,
+        forceMultipart: true,
+        params,
+        paramsInBody: false
+      },
+      callbacks: {
+        onComplete,
+        onUpload,
+        onValidateBatch,
+        onError,
+        onSubmit,
+        onCancel
+      },
+      template: templateId,
+      failedUploadTextDisplay: {
+        mode: 'custom',
+        responseProperty: 'error'
       }
-      return q
+    })
+    window.q = q
+    if (scope.control != null) {
+      scope.control.q = q
     }
-  })))
+    return q
+  }
+}))

+ 72 - 73
services/web/frontend/js/directives/focus.js

@@ -9,86 +9,85 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], function(App) {
-  let selectName
-  App.directive('focusWhen', $timeout => ({
-    restrict: 'A',
-    link(scope, element, attr) {
-      return scope.$watch(attr.focusWhen, function(value) {
-        if (value) {
-          return $timeout(() => element.focus())
-        }
-      })
-    }
-  }))
-
-  App.directive('focusOn', $timeout => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return scope.$on(attrs.focusOn, () => element.focus())
-    }
-  }))
+import App from '../base'
+let selectName
+App.directive('focusWhen', $timeout => ({
+  restrict: 'A',
+  link(scope, element, attr) {
+    return scope.$watch(attr.focusWhen, function(value) {
+      if (value) {
+        return $timeout(() => element.focus())
+      }
+    })
+  }
+}))
 
-  App.directive('selectWhen', $timeout => ({
-    restrict: 'A',
-    link(scope, element, attr) {
-      return scope.$watch(attr.selectWhen, function(value) {
-        if (value) {
-          return $timeout(() => element.select())
-        }
-      })
-    }
-  }))
+App.directive('focusOn', $timeout => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return scope.$on(attrs.focusOn, () => element.focus())
+  }
+}))
 
-  App.directive('selectOn', $timeout => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return scope.$on(attrs.selectOn, () => element.select())
-    }
-  }))
+App.directive('selectWhen', $timeout => ({
+  restrict: 'A',
+  link(scope, element, attr) {
+    return scope.$watch(attr.selectWhen, function(value) {
+      if (value) {
+        return $timeout(() => element.select())
+      }
+    })
+  }
+}))
 
-  App.directive('selectNameWhen', $timeout => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return scope.$watch(attrs.selectNameWhen, function(value) {
-        if (value) {
-          return $timeout(() => selectName(element))
-        }
-      })
-    }
-  }))
+App.directive('selectOn', $timeout => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return scope.$on(attrs.selectOn, () => element.select())
+  }
+}))
 
-  App.directive('selectNameOn', () => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return scope.$on(attrs.selectNameOn, () => selectName(element))
-    }
-  }))
+App.directive('selectNameWhen', $timeout => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return scope.$watch(attrs.selectNameWhen, function(value) {
+      if (value) {
+        return $timeout(() => selectName(element))
+      }
+    })
+  }
+}))
 
-  App.directive('focus', $timeout => ({
-    scope: {
-      trigger: '@focus'
-    },
+App.directive('selectNameOn', () => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return scope.$on(attrs.selectNameOn, () => selectName(element))
+  }
+}))
 
-    link(scope, element) {
-      return scope.$watch('trigger', function(value) {
-        if (value === 'true') {
-          return $timeout(() => element[0].focus())
-        }
-      })
-    }
-  }))
+App.directive('focus', $timeout => ({
+  scope: {
+    trigger: '@focus'
+  },
 
-  selectName = function(element) {
-    // Select up to last '.'. I.e. everything except the file extension
-    element.focus()
-    const name = element.val()
-    if (element[0].setSelectionRange != null) {
-      let selectionEnd = name.lastIndexOf('.')
-      if (selectionEnd === -1) {
-        selectionEnd = name.length
+  link(scope, element) {
+    return scope.$watch('trigger', function(value) {
+      if (value === 'true') {
+        return $timeout(() => element[0].focus())
       }
-      return element[0].setSelectionRange(0, selectionEnd)
+    })
+  }
+}))
+
+selectName = function(element) {
+  // Select up to last '.'. I.e. everything except the file extension
+  element.focus()
+  const name = element.val()
+  if (element[0].setSelectionRange != null) {
+    let selectionEnd = name.lastIndexOf('.')
+    if (selectionEnd === -1) {
+      selectionEnd = name.length
     }
+    return element[0].setSelectionRange(0, selectionEnd)
   }
-})
+}

+ 32 - 32
services/web/frontend/js/directives/mathjax.js

@@ -1,41 +1,41 @@
 /* global MathJax */
 
-define(['../base'], function(App) {
-  return App.directive('mathjax', function($compile, $parse) {
-    return {
-      link(scope, element, attrs) {
-        if (!(MathJax && MathJax.Hub)) return
+import App from '../base'
 
-        // Allowing HTML can be unsafe unless using something like
-        // `ng-bind-html` because of potential Angular XSS via {{/}}
-        if (!$parse(attrs.mathjaxAllowHtml)(scope)) {
-          const mathJaxContents = element.html()
-          const nonBindableEl = $compile('<span ng-non-bindable></span>')({})
-          element.html('').append(nonBindableEl)
-          nonBindableEl.html(mathJaxContents)
-        }
+export default App.directive('mathjax', function($compile, $parse) {
+  return {
+    link(scope, element, attrs) {
+      if (!(MathJax && MathJax.Hub)) return
 
-        if (attrs.delimiter !== 'no-single-dollar') {
-          const inlineMathConfig =
-            MathJax.Hub.config && MathJax.Hub.config.tex2jax.inlineMath
-          const alreadyConfigured = _.find(
-            inlineMathConfig,
-            c => c[0] === '$' && c[1] === '$'
-          )
+      // Allowing HTML can be unsafe unless using something like
+      // `ng-bind-html` because of potential Angular XSS via {{/}}
+      if (!$parse(attrs.mathjaxAllowHtml)(scope)) {
+        const mathJaxContents = element.html()
+        const nonBindableEl = $compile('<span ng-non-bindable></span>')({})
+        element.html('').append(nonBindableEl)
+        nonBindableEl.html(mathJaxContents)
+      }
 
-          if (!alreadyConfigured) {
-            MathJax.Hub.Config({
-              tex2jax: {
-                inlineMath: inlineMathConfig.concat([['$', '$']])
-              }
-            })
-          }
-        }
+      if (attrs.delimiter !== 'no-single-dollar') {
+        const inlineMathConfig =
+          MathJax.Hub.config && MathJax.Hub.config.tex2jax.inlineMath
+        const alreadyConfigured = _.find(
+          inlineMathConfig,
+          c => c[0] === '$' && c[1] === '$'
+        )
 
-        setTimeout(() => {
-          MathJax.Hub.Queue(['Typeset', MathJax.Hub, element.get(0)])
-        }, 0)
+        if (!alreadyConfigured) {
+          MathJax.Hub.Config({
+            tex2jax: {
+              inlineMath: inlineMathConfig.concat([['$', '$']])
+            }
+          })
+        }
       }
+
+      setTimeout(() => {
+        MathJax.Hub.Queue(['Typeset', MathJax.Hub, element.get(0)])
+      }, 0)
     }
-  })
+  }
 })

+ 12 - 11
services/web/frontend/js/directives/maxHeight.js

@@ -8,14 +8,15 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('maxHeight', () => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return scope.$watch(attrs.maxHeight, function(value) {
-        if (value != null) {
-          return element.css({ 'max-height': value })
-        }
-      })
-    }
-  })))
+import App from '../base'
+
+export default App.directive('maxHeight', () => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return scope.$watch(attrs.maxHeight, function(value) {
+      if (value != null) {
+        return element.css({ 'max-height': value })
+      }
+    })
+  }
+}))

+ 10 - 9
services/web/frontend/js/directives/onEnter.js

@@ -7,12 +7,13 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('onEnter', () => (scope, element, attrs) =>
-    element.bind('keydown keypress', function(event) {
-      if (event.which === 13) {
-        scope.$apply(() => scope.$eval(attrs.onEnter, { event }))
-        return event.preventDefault()
-      }
-    })
-  ))
+import App from '../base'
+
+export default App.directive('onEnter', () => (scope, element, attrs) =>
+  element.bind('keydown keypress', function(event) {
+    if (event.which === 13) {
+      scope.$apply(() => scope.$eval(attrs.onEnter, { event }))
+      return event.preventDefault()
+    }
+  })
+)

+ 12 - 11
services/web/frontend/js/directives/rightClick.js

@@ -7,14 +7,15 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('rightClick', () => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return element.bind('contextmenu', function(e) {
-        e.preventDefault()
-        e.stopPropagation()
-        return scope.$eval(attrs.rightClick)
-      })
-    }
-  })))
+import App from '../base'
+
+export default App.directive('rightClick', () => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return element.bind('contextmenu', function(e) {
+      e.preventDefault()
+      e.stopPropagation()
+      return scope.$eval(attrs.rightClick)
+    })
+  }
+}))

+ 42 - 41
services/web/frontend/js/directives/scroll.js

@@ -9,47 +9,48 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('updateScrollBottomOn', $timeout => ({
-    restrict: 'A',
-    link(scope, element, attrs, ctrls) {
-      // We keep the offset from the bottom fixed whenever the event fires
-      //
-      // ^   | ^
-      // |   | | scrollTop
-      // |   | v
-      // |   |-----------
-      // |   | ^
-      // |   | |
-      // |   | | clientHeight (viewable area)
-      // |   | |
-      // |   | |
-      // |   | v
-      // |   |-----------
-      // |   | ^
-      // |   | | scrollBottom
-      // v   | v
-      //  \
-      //   scrollHeight
+import App from '../base'
 
-      let scrollBottom = 0
-      element.on(
-        'scroll',
-        e =>
-          (scrollBottom =
-            element[0].scrollHeight -
-            element[0].scrollTop -
-            element[0].clientHeight)
-      )
+export default App.directive('updateScrollBottomOn', $timeout => ({
+  restrict: 'A',
+  link(scope, element, attrs, ctrls) {
+    // We keep the offset from the bottom fixed whenever the event fires
+    //
+    // ^   | ^
+    // |   | | scrollTop
+    // |   | v
+    // |   |-----------
+    // |   | ^
+    // |   | |
+    // |   | | clientHeight (viewable area)
+    // |   | |
+    // |   | |
+    // |   | v
+    // |   |-----------
+    // |   | ^
+    // |   | | scrollBottom
+    // v   | v
+    //  \
+    //   scrollHeight
+
+    let scrollBottom = 0
+    element.on(
+      'scroll',
+      e =>
+        (scrollBottom =
+          element[0].scrollHeight -
+          element[0].scrollTop -
+          element[0].clientHeight)
+    )
 
-      return scope.$on(attrs.updateScrollBottomOn, () =>
-        $timeout(
-          () =>
-            element.scrollTop(
-              element[0].scrollHeight - element[0].clientHeight - scrollBottom
-            ),
-          0
-        )
+    return scope.$on(attrs.updateScrollBottomOn, () =>
+      $timeout(
+        () =>
+          element.scrollTop(
+            element[0].scrollHeight - element[0].clientHeight - scrollBottom
+          ),
+        0
       )
-    }
-  })))
+    )
+  }
+}))

+ 74 - 77
services/web/frontend/js/directives/selectAll.js

@@ -10,92 +10,89 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], function(App) {
-  App.directive('selectAllList', () => ({
-    controller: [
-      '$scope',
-      function($scope) {
-        // Selecting or deselecting all should apply to all projects
-        this.selectAll = () => $scope.$broadcast('select-all:select')
+import App from '../base'
+App.directive('selectAllList', () => ({
+  controller: [
+    '$scope',
+    function($scope) {
+      // Selecting or deselecting all should apply to all projects
+      this.selectAll = () => $scope.$broadcast('select-all:select')
 
-        this.deselectAll = () => $scope.$broadcast('select-all:deselect')
+      this.deselectAll = () => $scope.$broadcast('select-all:deselect')
 
-        this.clearSelectAllState = () => $scope.$broadcast('select-all:clear')
-      }
-    ],
-    link(scope, element, attrs) {}
-  }))
+      this.clearSelectAllState = () => $scope.$broadcast('select-all:clear')
+    }
+  ],
+  link(scope, element, attrs) {}
+}))
 
-  App.directive('selectAll', () => ({
-    require: '^selectAllList',
-    link(scope, element, attrs, selectAllListController) {
-      scope.$on('select-all:clear', () => element.prop('checked', false))
+App.directive('selectAll', () => ({
+  require: '^selectAllList',
+  link(scope, element, attrs, selectAllListController) {
+    scope.$on('select-all:clear', () => element.prop('checked', false))
 
-      return element.change(function() {
-        if (element.is(':checked')) {
-          selectAllListController.selectAll()
-        } else {
-          selectAllListController.deselectAll()
-        }
-        return true
-      })
-    }
-  }))
+    return element.change(function() {
+      if (element.is(':checked')) {
+        selectAllListController.selectAll()
+      } else {
+        selectAllListController.deselectAll()
+      }
+      return true
+    })
+  }
+}))
 
-  App.directive('selectIndividual', () => ({
-    require: '^selectAllList',
-    scope: {
-      ngModel: '='
-    },
-    link(scope, element, attrs, selectAllListController) {
-      let ignoreChanges = false
+App.directive('selectIndividual', () => ({
+  require: '^selectAllList',
+  scope: {
+    ngModel: '='
+  },
+  link(scope, element, attrs, selectAllListController) {
+    let ignoreChanges = false
 
-      scope.$watch('ngModel', function(value) {
-        if (value != null && !ignoreChanges) {
-          return selectAllListController.clearSelectAllState()
-        }
-      })
+    scope.$watch('ngModel', function(value) {
+      if (value != null && !ignoreChanges) {
+        return selectAllListController.clearSelectAllState()
+      }
+    })
 
-      scope.$on('select-all:select', function() {
-        if (element.prop('disabled')) {
-          return
-        }
-        ignoreChanges = true
-        scope.$apply(() => (scope.ngModel = true))
-        return (ignoreChanges = false)
-      })
+    scope.$on('select-all:select', function() {
+      if (element.prop('disabled')) {
+        return
+      }
+      ignoreChanges = true
+      scope.$apply(() => (scope.ngModel = true))
+      return (ignoreChanges = false)
+    })
 
-      scope.$on('select-all:deselect', function() {
-        if (element.prop('disabled')) {
-          return
-        }
-        ignoreChanges = true
-        scope.$apply(() => (scope.ngModel = false))
-        return (ignoreChanges = false)
-      })
+    scope.$on('select-all:deselect', function() {
+      if (element.prop('disabled')) {
+        return
+      }
+      ignoreChanges = true
+      scope.$apply(() => (scope.ngModel = false))
+      return (ignoreChanges = false)
+    })
 
-      return scope.$on('select-all:row-clicked', function() {
-        if (element.prop('disabled')) {
-          return
+    return scope.$on('select-all:row-clicked', function() {
+      if (element.prop('disabled')) {
+        return
+      }
+      ignoreChanges = true
+      scope.$apply(function() {
+        scope.ngModel = !scope.ngModel
+        if (!scope.ngModel) {
+          return selectAllListController.clearSelectAllState()
         }
-        ignoreChanges = true
-        scope.$apply(function() {
-          scope.ngModel = !scope.ngModel
-          if (!scope.ngModel) {
-            return selectAllListController.clearSelectAllState()
-          }
-        })
-        return (ignoreChanges = false)
       })
-    }
-  }))
+      return (ignoreChanges = false)
+    })
+  }
+}))
 
-  return App.directive('selectRow', () => ({
-    scope: true,
-    link(scope, element, attrs) {
-      return element.on('click', e =>
-        scope.$broadcast('select-all:row-clicked')
-      )
-    }
-  }))
-})
+export default App.directive('selectRow', () => ({
+  scope: true,
+  link(scope, element, attrs) {
+    return element.on('click', e => scope.$broadcast('select-all:row-clicked'))
+  }
+}))

+ 13 - 14
services/web/frontend/js/directives/stopPropagation.js

@@ -8,18 +8,17 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], function(App) {
-  App.directive('stopPropagation', $http => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return element.bind(attrs.stopPropagation, e => e.stopPropagation())
-    }
-  }))
+import App from '../base'
+App.directive('stopPropagation', $http => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return element.bind(attrs.stopPropagation, e => e.stopPropagation())
+  }
+}))
 
-  return App.directive('preventDefault', $http => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      return element.bind(attrs.preventDefault, e => e.preventDefault())
-    }
-  }))
-})
+export default App.directive('preventDefault', $http => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    return element.bind(attrs.preventDefault, e => e.preventDefault())
+  }
+}))

+ 18 - 17
services/web/frontend/js/directives/videoPlayState.js

@@ -8,20 +8,21 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], App =>
-  App.directive('videoPlayState', $parse => ({
-    restrict: 'A',
-    link(scope, element, attrs) {
-      const videoDOMEl = element[0]
-      return scope.$watch(() => $parse(attrs.videoPlayState)(scope), function(
-        shouldPlay
-      ) {
-        if (shouldPlay) {
-          videoDOMEl.currentTime = 0
-          return videoDOMEl.play()
-        } else {
-          return videoDOMEl.pause()
-        }
-      })
-    }
-  })))
+import App from '../base'
+
+export default App.directive('videoPlayState', $parse => ({
+  restrict: 'A',
+  link(scope, element, attrs) {
+    const videoDOMEl = element[0]
+    return scope.$watch(() => $parse(attrs.videoPlayState)(scope), function(
+      shouldPlay
+    ) {
+      if (shouldPlay) {
+        videoDOMEl.currentTime = 0
+        return videoDOMEl.play()
+      } else {
+        return videoDOMEl.pause()
+      }
+    })
+  }
+}))

+ 24 - 24
services/web/frontend/js/filters/formatDate.js

@@ -8,31 +8,31 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base', 'moment'], function(App, moment) {
-  moment.updateLocale('en', {
-    calendar: {
-      lastDay: '[Yesterday]',
-      sameDay: '[Today]',
-      nextDay: '[Tomorrow]',
-      lastWeek: 'ddd, Do MMM YY',
-      nextWeek: 'ddd, Do MMM YY',
-      sameElse: 'ddd, Do MMM YY'
-    }
-  })
+import App from '../base'
+import moment from 'moment'
+moment.updateLocale('en', {
+  calendar: {
+    lastDay: '[Yesterday]',
+    sameDay: '[Today]',
+    nextDay: '[Tomorrow]',
+    lastWeek: 'ddd, Do MMM YY',
+    nextWeek: 'ddd, Do MMM YY',
+    sameElse: 'ddd, Do MMM YY'
+  }
+})
 
-  App.filter(
-    'formatDate',
-    () =>
-      function(date, format) {
-        if (!date) return 'N/A'
-        if (format == null) {
-          format = 'Do MMM YYYY, h:mm a'
-        }
-        return moment(date).format(format)
+App.filter(
+  'formatDate',
+  () =>
+    function(date, format) {
+      if (!date) return 'N/A'
+      if (format == null) {
+        format = 'Do MMM YYYY, h:mm a'
       }
-  )
+      return moment(date).format(format)
+    }
+)
 
-  App.filter('relativeDate', () => date => moment(date).calendar())
+App.filter('relativeDate', () => date => moment(date).calendar())
 
-  App.filter('fromNowDate', () => date => moment(date).fromNow())
-})
+App.filter('fromNowDate', () => date => moment(date).fromNow())

+ 19 - 20
services/web/frontend/js/filters/wrapLongWords.js

@@ -12,28 +12,27 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../base'], function(App) {
-  const DEF_MIN_LENGTH = 20
+import App from '../base'
+const DEF_MIN_LENGTH = 20
 
-  const _decodeHTMLEntities = str =>
-    str.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
+const _decodeHTMLEntities = str =>
+  str.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
 
-  const _getWrappedWordsString = function(baseStr, wrapperElName, minLength) {
-    let outputStr
-    minLength = minLength || DEF_MIN_LENGTH
-    const words = baseStr.split(' ')
+const _getWrappedWordsString = function(baseStr, wrapperElName, minLength) {
+  let outputStr
+  minLength = minLength || DEF_MIN_LENGTH
+  const words = baseStr.split(' ')
 
-    const wordsWrapped = Array.from(words).map(
-      word =>
-        _decodeHTMLEntities(word).length >= minLength
-          ? `<${wrapperElName} class=\"break-word\">${word}</${wrapperElName}>`
-          : word
-    )
+  const wordsWrapped = Array.from(words).map(
+    word =>
+      _decodeHTMLEntities(word).length >= minLength
+        ? `<${wrapperElName} class=\"break-word\">${word}</${wrapperElName}>`
+        : word
+  )
 
-    return (outputStr = wordsWrapped.join(' '))
-  }
+  return (outputStr = wordsWrapped.join(' '))
+}
 
-  return App.filter('wrapLongWords', () => (input, minLength) =>
-    _getWrappedWordsString(input, 'span', minLength)
-  )
-})
+export default App.filter('wrapLongWords', () => (input, minLength) =>
+  _getWrappedWordsString(input, 'span', minLength)
+)

+ 299 - 317
services/web/frontend/js/ide.js

@@ -16,355 +16,337 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  './base',
-  './ide/file-tree/FileTreeManager',
-  './ide/connection/ConnectionManager',
-  './ide/editor/EditorManager',
-  './ide/online-users/OnlineUsersManager',
-  './ide/history/HistoryManager',
-  './ide/history/HistoryV2Manager',
-  './ide/permissions/PermissionsManager',
-  './ide/pdf/PdfManager',
-  './ide/binary-files/BinaryFilesManager',
-  './ide/references/ReferencesManager',
-  './ide/metadata/MetadataManager',
-  './ide/review-panel/ReviewPanelManager',
-  './ide/SafariScrollPatcher',
-  './ide/cobranding/CobrandingDataService',
-  './ide/settings/index',
-  './ide/share/index',
-  './ide/chat/index',
-  './ide/clone/index',
-  './ide/hotkeys/index',
-  './ide/wordcount/index',
-  './ide/directives/layout',
-  './ide/directives/validFile',
-  './ide/services/ide',
-  './analytics/AbTestingManager',
-  './directives/focus',
-  './directives/fineUpload',
-  './directives/scroll',
-  './directives/onEnter',
-  './directives/stopPropagation',
-  './directives/rightClick',
-  './directives/expandableTextArea',
-  './directives/videoPlayState',
-  './services/queued-http',
-  './services/validateCaptcha',
-  './services/validateCaptchaV3',
-  './services/wait-for',
-  './filters/formatDate',
-  './main/event',
-  './main/account-upgrade',
-  './main/exposed-settings',
-  './main/system-messages',
-  '../../modules/modules-ide.js'
-], function(
-  App,
-  FileTreeManager,
-  ConnectionManager,
-  EditorManager,
-  OnlineUsersManager,
-  HistoryManager,
-  HistoryV2Manager,
-  PermissionsManager,
-  PdfManager,
-  BinaryFilesManager,
-  ReferencesManager,
-  MetadataManager,
-  ReviewPanelManager,
-  SafariScrollPatcher
+import App from './base'
+import FileTreeManager from './ide/file-tree/FileTreeManager'
+import ConnectionManager from './ide/connection/ConnectionManager'
+import EditorManager from './ide/editor/EditorManager'
+import OnlineUsersManager from './ide/online-users/OnlineUsersManager'
+import HistoryManager from './ide/history/HistoryManager'
+import HistoryV2Manager from './ide/history/HistoryV2Manager'
+import PermissionsManager from './ide/permissions/PermissionsManager'
+import PdfManager from './ide/pdf/PdfManager'
+import BinaryFilesManager from './ide/binary-files/BinaryFilesManager'
+import ReferencesManager from './ide/references/ReferencesManager'
+import MetadataManager from './ide/metadata/MetadataManager'
+import ReviewPanelManager from './ide/review-panel/ReviewPanelManager'
+import SafariScrollPatcher from './ide/SafariScrollPatcher'
+import './ide/cobranding/CobrandingDataService'
+import './ide/settings/index'
+import './ide/share/index'
+import './ide/chat/index'
+import './ide/clone/index'
+import './ide/hotkeys/index'
+import './ide/wordcount/index'
+import './ide/directives/layout'
+import './ide/directives/validFile'
+import './ide/services/ide'
+import './analytics/AbTestingManager'
+import './directives/focus'
+import './directives/fineUpload'
+import './directives/scroll'
+import './directives/onEnter'
+import './directives/stopPropagation'
+import './directives/rightClick'
+import './directives/expandableTextArea'
+import './directives/videoPlayState'
+import './services/queued-http'
+import './services/validateCaptcha'
+import './services/validateCaptchaV3'
+import './services/wait-for'
+import './filters/formatDate'
+import './main/event'
+import './main/account-upgrade'
+import './main/exposed-settings'
+import './main/system-messages'
+import '../../modules/modules-ide.js'
+App.controller('IdeController', function(
+  $scope,
+  $timeout,
+  ide,
+  localStorage,
+  eventTracking,
+  metadata,
+  $q,
+  CobrandingDataService
 ) {
-  App.controller('IdeController', function(
-    $scope,
-    $timeout,
-    ide,
-    localStorage,
-    eventTracking,
-    metadata,
-    $q,
-    CobrandingDataService
-  ) {
-    // Don't freak out if we're already in an apply callback
-    let err, pdfLayout, userAgent
-    $scope.$originalApply = $scope.$apply
-    $scope.$apply = function(fn) {
-      if (fn == null) {
-        fn = function() {}
-      }
-      const phase = this.$root.$$phase
-      if (phase === '$apply' || phase === '$digest') {
-        return fn()
-      } else {
-        return this.$originalApply(fn)
-      }
-    }
-
-    $scope.state = {
-      loading: true,
-      load_progress: 40,
-      error: null
+  // Don't freak out if we're already in an apply callback
+  let err, pdfLayout, userAgent
+  $scope.$originalApply = $scope.$apply
+  $scope.$apply = function(fn) {
+    if (fn == null) {
+      fn = function() {}
     }
-    $scope.ui = {
-      leftMenuShown: false,
-      view: 'editor',
-      chatOpen: false,
-      pdfLayout: 'sideBySide',
-      pdfHidden: false,
-      pdfWidth: 0,
-      reviewPanelOpen: localStorage(`ui.reviewPanelOpen.${window.project_id}`),
-      miniReviewPanelVisible: false,
-      chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen,
-      chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed
-    }
-    $scope.user = window.user
-
-    $scope.settings = window.userSettings
-    $scope.anonymous = window.anonymous
-    $scope.isTokenMember = window.isTokenMember
-    $scope.isRestrictedTokenMember = window.isRestrictedTokenMember
-
-    $scope.cobranding = {
-      isProjectCobranded: CobrandingDataService.isProjectCobranded(),
-      logoImgUrl: CobrandingDataService.getLogoImgUrl(),
-      submitBtnHtml: CobrandingDataService.getSubmitBtnHtml(),
-      brandVariationName: CobrandingDataService.getBrandVariationName(),
-      brandVariationHomeUrl: CobrandingDataService.getBrandVariationHomeUrl()
+    const phase = this.$root.$$phase
+    if (phase === '$apply' || phase === '$digest') {
+      return fn()
+    } else {
+      return this.$originalApply(fn)
     }
-
-    $scope.chat = {}
-
-    ide.toggleReviewPanel = $scope.toggleReviewPanel = function() {
-      if (!$scope.project.features.trackChangesVisible) {
-        return
-      }
-      $scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
-      return eventTracking.sendMB('rp-toggle-panel', {
-        value: $scope.ui.reviewPanelOpen
-      })
+  }
+
+  $scope.state = {
+    loading: true,
+    load_progress: 40,
+    error: null
+  }
+  $scope.ui = {
+    leftMenuShown: false,
+    view: 'editor',
+    chatOpen: false,
+    pdfLayout: 'sideBySide',
+    pdfHidden: false,
+    pdfWidth: 0,
+    reviewPanelOpen: localStorage(`ui.reviewPanelOpen.${window.project_id}`),
+    miniReviewPanelVisible: false,
+    chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen,
+    chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed
+  }
+  $scope.user = window.user
+
+  $scope.settings = window.userSettings
+  $scope.anonymous = window.anonymous
+  $scope.isTokenMember = window.isTokenMember
+  $scope.isRestrictedTokenMember = window.isRestrictedTokenMember
+
+  $scope.cobranding = {
+    isProjectCobranded: CobrandingDataService.isProjectCobranded(),
+    logoImgUrl: CobrandingDataService.getLogoImgUrl(),
+    submitBtnHtml: CobrandingDataService.getSubmitBtnHtml(),
+    brandVariationName: CobrandingDataService.getBrandVariationName(),
+    brandVariationHomeUrl: CobrandingDataService.getBrandVariationHomeUrl()
+  }
+
+  $scope.chat = {}
+
+  ide.toggleReviewPanel = $scope.toggleReviewPanel = function() {
+    if (!$scope.project.features.trackChangesVisible) {
+      return
     }
-
-    $scope.$watch('ui.reviewPanelOpen', function(value) {
-      if (value != null) {
-        return localStorage(`ui.reviewPanelOpen.${window.project_id}`, value)
-      }
-    })
-
-    $scope.$on('layout:pdf:resize', function(_, layoutState) {
-      $scope.ui.pdfHidden = layoutState.east.initClosed
-      return ($scope.ui.pdfWidth = layoutState.east.size)
-    })
-
-    $scope.$watch('ui.view', function(newView, oldView) {
-      if (newView !== oldView) {
-        $scope.$broadcast('layout:flat-screen:toggle')
-      }
-      if (newView != null && newView !== 'editor' && newView !== 'pdf') {
-        return eventTracking.sendMBOnce(`ide-open-view-${newView}-once`)
-      }
-    })
-
-    $scope.$watch('ui.chatOpen', function(isOpen) {
-      if (isOpen) {
-        return eventTracking.sendMBOnce('ide-open-chat-once')
-      }
+    $scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
+    return eventTracking.sendMB('rp-toggle-panel', {
+      value: $scope.ui.reviewPanelOpen
     })
+  }
 
-    $scope.$watch('ui.leftMenuShown', function(isOpen) {
-      if (isOpen) {
-        return eventTracking.sendMBOnce('ide-open-left-menu-once')
-      }
-    })
-
-    $scope.trackHover = feature =>
-      eventTracking.sendMBOnce(`ide-hover-${feature}-once`)
-    // End of tracking code.
+  $scope.$watch('ui.reviewPanelOpen', function(value) {
+    if (value != null) {
+      return localStorage(`ui.reviewPanelOpen.${window.project_id}`, value)
+    }
+  })
 
-    window._ide = ide
+  $scope.$on('layout:pdf:resize', function(_, layoutState) {
+    $scope.ui.pdfHidden = layoutState.east.initClosed
+    return ($scope.ui.pdfWidth = layoutState.east.size)
+  })
 
-    ide.validFileRegex = '^[^*/]*$' // Don't allow * and /
+  $scope.$watch('ui.view', function(newView, oldView) {
+    if (newView !== oldView) {
+      $scope.$broadcast('layout:flat-screen:toggle')
+    }
+    if (newView != null && newView !== 'editor' && newView !== 'pdf') {
+      return eventTracking.sendMBOnce(`ide-open-view-${newView}-once`)
+    }
+  })
 
-    let useFallbackWebsocket =
-      window.location &&
-      window.location.search &&
-      window.location.search.match(/ws=fallback/)
-    // if we previously failed to load the websocket fall back to null (the siteUrl)
-    ide.wsUrl = useFallbackWebsocket ? null : window.sharelatex.wsUrl || null // websocket url (if defined)
+  $scope.$watch('ui.chatOpen', function(isOpen) {
+    if (isOpen) {
+      return eventTracking.sendMBOnce('ide-open-chat-once')
+    }
+  })
 
-    ide.project_id = $scope.project_id = window.project_id
-    ide.$scope = $scope
+  $scope.$watch('ui.leftMenuShown', function(isOpen) {
+    if (isOpen) {
+      return eventTracking.sendMBOnce('ide-open-left-menu-once')
+    }
+  })
 
-    ide.referencesSearchManager = new ReferencesManager(ide, $scope)
-    ide.connectionManager = new ConnectionManager(ide, $scope)
-    ide.fileTreeManager = new FileTreeManager(ide, $scope)
-    ide.editorManager = new EditorManager(ide, $scope, localStorage)
-    ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
-    if (window.data.useV2History) {
-      ide.historyManager = new HistoryV2Manager(ide, $scope, localStorage)
-    } else {
-      ide.historyManager = new HistoryManager(ide, $scope)
+  $scope.trackHover = feature =>
+    eventTracking.sendMBOnce(`ide-hover-${feature}-once`)
+  // End of tracking code.
+
+  window._ide = ide
+
+  ide.validFileRegex = '^[^*/]*$' // Don't allow * and /
+
+  let useFallbackWebsocket =
+    window.location &&
+    window.location.search &&
+    window.location.search.match(/ws=fallback/)
+  // if we previously failed to load the websocket fall back to null (the siteUrl)
+  ide.wsUrl = useFallbackWebsocket ? null : window.sharelatex.wsUrl || null // websocket url (if defined)
+
+  ide.project_id = $scope.project_id = window.project_id
+  ide.$scope = $scope
+
+  ide.referencesSearchManager = new ReferencesManager(ide, $scope)
+  ide.connectionManager = new ConnectionManager(ide, $scope)
+  ide.fileTreeManager = new FileTreeManager(ide, $scope)
+  ide.editorManager = new EditorManager(ide, $scope, localStorage)
+  ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
+  if (window.data.useV2History) {
+    ide.historyManager = new HistoryV2Manager(ide, $scope, localStorage)
+  } else {
+    ide.historyManager = new HistoryManager(ide, $scope)
+  }
+  ide.pdfManager = new PdfManager(ide, $scope)
+  ide.permissionsManager = new PermissionsManager(ide, $scope)
+  ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
+  ide.metadataManager = new MetadataManager(ide, $scope, metadata)
+
+  let inited = false
+  $scope.$on('project:joined', function() {
+    if (inited) {
+      return
     }
-    ide.pdfManager = new PdfManager(ide, $scope)
-    ide.permissionsManager = new PermissionsManager(ide, $scope)
-    ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
-    ide.metadataManager = new MetadataManager(ide, $scope, metadata)
-
-    let inited = false
-    $scope.$on('project:joined', function() {
-      if (inited) {
-        return
-      }
-      inited = true
-      if (
-        __guard__(
-          $scope != null ? $scope.project : undefined,
-          x => x.deletedByExternalDataSource
-        )
-      ) {
-        ide.showGenericMessageModal(
-          'Project Renamed or Deleted',
-          `\
+    inited = true
+    if (
+      __guard__(
+        $scope != null ? $scope.project : undefined,
+        x => x.deletedByExternalDataSource
+      )
+    ) {
+      ide.showGenericMessageModal(
+        'Project Renamed or Deleted',
+        `\
 This project has either been renamed or deleted by an external data source such as Dropbox.
 We don't want to delete your data on Overleaf, so this project still contains your history and collaborators.
 If the project has been renamed please look in your project list for a new project under the new name.\
 `
-        )
+      )
+    }
+    return $timeout(function() {
+      if ($scope.permissions.write) {
+        let _labelsInitialLoadDone
+        ide.metadataManager.loadProjectMetaFromServer()
+        return (_labelsInitialLoadDone = true)
       }
-      return $timeout(function() {
-        if ($scope.permissions.write) {
-          let _labelsInitialLoadDone
-          ide.metadataManager.loadProjectMetaFromServer()
-          return (_labelsInitialLoadDone = true)
-        }
-      }, 200)
-    })
+    }, 200)
+  })
 
-    // Count the first 'doc:opened' as a sign that the ide is loaded
-    // and broadcast a message. This is a good event to listen for
-    // if you want to wait until the ide is fully loaded and initialized
-    let _loaded = false
-    $scope.$on('doc:opened', function() {
-      if (_loaded) {
-        return
-      }
-      $scope.$broadcast('ide:loaded')
-      return (_loaded = true)
-    })
+  // Count the first 'doc:opened' as a sign that the ide is loaded
+  // and broadcast a message. This is a good event to listen for
+  // if you want to wait until the ide is fully loaded and initialized
+  let _loaded = false
+  $scope.$on('doc:opened', function() {
+    if (_loaded) {
+      return
+    }
+    $scope.$broadcast('ide:loaded')
+    return (_loaded = true)
+  })
 
-    $scope.$on('cursor:editor:update', eventTracking.editingSessionHeartbeat)
-
-    const DARK_THEMES = [
-      'ambiance',
-      'chaos',
-      'clouds_midnight',
-      'cobalt',
-      'idle_fingers',
-      'merbivore',
-      'merbivore_soft',
-      'mono_industrial',
-      'monokai',
-      'pastel_on_dark',
-      'solarized_dark',
-      'terminal',
-      'tomorrow_night',
-      'tomorrow_night_blue',
-      'tomorrow_night_bright',
-      'tomorrow_night_eighties',
-      'twilight',
-      'vibrant_ink'
-    ]
-    $scope.darkTheme = false
-    $scope.$watch('settings.editorTheme', function(theme) {
-      if (Array.from(DARK_THEMES).includes(theme)) {
-        return ($scope.darkTheme = true)
-      } else {
-        return ($scope.darkTheme = false)
-      }
-    })
+  $scope.$on('cursor:editor:update', eventTracking.editingSessionHeartbeat)
+
+  const DARK_THEMES = [
+    'ambiance',
+    'chaos',
+    'clouds_midnight',
+    'cobalt',
+    'idle_fingers',
+    'merbivore',
+    'merbivore_soft',
+    'mono_industrial',
+    'monokai',
+    'pastel_on_dark',
+    'solarized_dark',
+    'terminal',
+    'tomorrow_night',
+    'tomorrow_night_blue',
+    'tomorrow_night_bright',
+    'tomorrow_night_eighties',
+    'twilight',
+    'vibrant_ink'
+  ]
+  $scope.darkTheme = false
+  $scope.$watch('settings.editorTheme', function(theme) {
+    if (Array.from(DARK_THEMES).includes(theme)) {
+      return ($scope.darkTheme = true)
+    } else {
+      return ($scope.darkTheme = false)
+    }
+  })
 
-    ide.localStorage = localStorage
+  ide.localStorage = localStorage
 
-    ide.browserIsSafari = false
+  ide.browserIsSafari = false
 
-    $scope.switchToFlatLayout = function(view) {
-      $scope.ui.pdfLayout = 'flat'
-      $scope.ui.view = view
-      return ide.localStorage('pdf.layout', 'flat')
-    }
+  $scope.switchToFlatLayout = function(view) {
+    $scope.ui.pdfLayout = 'flat'
+    $scope.ui.view = view
+    return ide.localStorage('pdf.layout', 'flat')
+  }
 
-    $scope.switchToSideBySideLayout = function(view) {
-      $scope.ui.pdfLayout = 'sideBySide'
-      $scope.ui.view = view
-      return localStorage('pdf.layout', 'split')
-    }
+  $scope.switchToSideBySideLayout = function(view) {
+    $scope.ui.pdfLayout = 'sideBySide'
+    $scope.ui.view = view
+    return localStorage('pdf.layout', 'split')
+  }
 
-    if ((pdfLayout = localStorage('pdf.layout'))) {
-      if (pdfLayout === 'split') {
-        $scope.switchToSideBySideLayout()
-      }
-      if (pdfLayout === 'flat') {
-        $scope.switchToFlatLayout()
-      }
-    } else {
+  if ((pdfLayout = localStorage('pdf.layout'))) {
+    if (pdfLayout === 'split') {
       $scope.switchToSideBySideLayout()
     }
-
-    try {
-      ;({ userAgent } = navigator)
-      ide.browserIsSafari =
-        userAgent &&
-        /.*Safari\/.*/.test(userAgent) &&
-        !/.*Chrome\/.*/.test(userAgent) &&
-        !/.*Chromium\/.*/.test(userAgent)
-    } catch (error) {
-      err = error
-      console.error(err)
+    if (pdfLayout === 'flat') {
+      $scope.switchToFlatLayout()
     }
-
-    if (ide.browserIsSafari) {
-      ide.safariScrollPatcher = new SafariScrollPatcher($scope)
+  } else {
+    $scope.switchToSideBySideLayout()
+  }
+
+  try {
+    ;({ userAgent } = navigator)
+    ide.browserIsSafari =
+      userAgent &&
+      /.*Safari\/.*/.test(userAgent) &&
+      !/.*Chrome\/.*/.test(userAgent) &&
+      !/.*Chromium\/.*/.test(userAgent)
+  } catch (error) {
+    err = error
+    console.error(err)
+  }
+
+  if (ide.browserIsSafari) {
+    ide.safariScrollPatcher = new SafariScrollPatcher($scope)
+  }
+
+  // Fix Chrome 61 and 62 text-shadow rendering
+  let browserIsChrome61or62 = false
+  try {
+    const chromeVersion =
+      parseFloat(navigator.userAgent.split(' Chrome/')[1]) || null
+    browserIsChrome61or62 = chromeVersion != null
+    if (browserIsChrome61or62) {
+      document.styleSheets[0].insertRule(
+        '.ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }',
+        1
+      )
     }
-
-    // Fix Chrome 61 and 62 text-shadow rendering
-    let browserIsChrome61or62 = false
-    try {
-      const chromeVersion =
-        parseFloat(navigator.userAgent.split(' Chrome/')[1]) || null
-      browserIsChrome61or62 = chromeVersion != null
-      if (browserIsChrome61or62) {
-        document.styleSheets[0].insertRule(
-          '.ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }',
-          1
-        )
-      }
-    } catch (error1) {
-      err = error1
-      console.error(err)
+  } catch (error1) {
+    err = error1
+    console.error(err)
+  }
+
+  // User can append ?ft=somefeature to url to activate a feature toggle
+  ide.featureToggle = __guard__(
+    __guard__(
+      typeof location !== 'undefined' && location !== null
+        ? location.search
+        : undefined,
+      x1 => x1.match(/^\?ft=(\w+)$/)
+    ),
+    x => x[1]
+  )
+
+  return ide.socket.on('project:publicAccessLevel:changed', data => {
+    if (data.newAccessLevel != null) {
+      ide.$scope.project.publicAccesLevel = data.newAccessLevel
+      return $scope.$digest()
     }
-
-    // User can append ?ft=somefeature to url to activate a feature toggle
-    ide.featureToggle = __guard__(
-      __guard__(
-        typeof location !== 'undefined' && location !== null
-          ? location.search
-          : undefined,
-        x1 => x1.match(/^\?ft=(\w+)$/)
-      ),
-      x => x[1]
-    )
-
-    return ide.socket.on('project:publicAccessLevel:changed', data => {
-      if (data.newAccessLevel != null) {
-        ide.$scope.project.publicAccesLevel = data.newAccessLevel
-        return $scope.$digest()
-      }
-    })
   })
-
-  return angular.bootstrap(document.body, ['SharelatexApp'])
 })
 
+export default angular.bootstrap(document.body, ['SharelatexApp'])
+
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null
     ? transform(value)

+ 76 - 77
services/web/frontend/js/ide/SafariScrollPatcher.js

@@ -11,95 +11,94 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  let SafariScrollPatcher
-  return (SafariScrollPatcher = class SafariScrollPatcher {
-    constructor($scope) {
-      this.isOverAce = false // Flag to control if the pointer is over Ace.
-      this.pdfDiv = null
-      this.aceDiv = null
+let SafariScrollPatcher
 
-      // Start listening to PDF wheel events when the pointer leaves the PDF region.
-      // P.S. This is the problem in a nutshell: although the pointer is elsewhere,
-      // wheel events keep being dispatched to the PDF.
-      this.handlePdfDivMouseLeave = () => {
-        return this.pdfDiv.addEventListener('wheel', this.dispatchToAce)
-      }
-
-      // Stop listening to wheel events when the pointer enters the PDF region. If
-      // the pointer is over the PDF, native behaviour is adequate.
-      this.handlePdfDivMouseEnter = () => {
-        return this.pdfDiv.removeEventListener('wheel', this.dispatchToAce)
-      }
+export default (SafariScrollPatcher = class SafariScrollPatcher {
+  constructor($scope) {
+    this.isOverAce = false // Flag to control if the pointer is over Ace.
+    this.pdfDiv = null
+    this.aceDiv = null
 
-      // Set the "pointer over Ace" flag as false, when the mouse leaves its area.
-      this.handleAceDivMouseLeave = () => {
-        return (this.isOverAce = false)
-      }
+    // Start listening to PDF wheel events when the pointer leaves the PDF region.
+    // P.S. This is the problem in a nutshell: although the pointer is elsewhere,
+    // wheel events keep being dispatched to the PDF.
+    this.handlePdfDivMouseLeave = () => {
+      return this.pdfDiv.addEventListener('wheel', this.dispatchToAce)
+    }
 
-      // Set the "pointer over Ace" flag as true, when the mouse enters its area.
-      this.handleAceDivMouseEnter = () => {
-        return (this.isOverAce = true)
-      }
+    // Stop listening to wheel events when the pointer enters the PDF region. If
+    // the pointer is over the PDF, native behaviour is adequate.
+    this.handlePdfDivMouseEnter = () => {
+      return this.pdfDiv.removeEventListener('wheel', this.dispatchToAce)
+    }
 
-      // Grab the elements (pdfDiv, aceDiv) and set the "hover" event listeners.
-      // If elements are already defined, clear existing event listeners and do
-      // the process again (grab elements, set listeners).
-      this.setListeners = () => {
-        this.isOverAce = false
+    // Set the "pointer over Ace" flag as false, when the mouse leaves its area.
+    this.handleAceDivMouseLeave = () => {
+      return (this.isOverAce = false)
+    }
 
-        // If elements aren't null, remove existing listeners.
-        if (this.pdfDiv != null) {
-          this.pdfDiv.removeEventListener(this.handlePdfDivMouseLeave)
-          this.pdfDiv.removeEventListener(this.handlePdfDivMouseEnter)
-        }
+    // Set the "pointer over Ace" flag as true, when the mouse enters its area.
+    this.handleAceDivMouseEnter = () => {
+      return (this.isOverAce = true)
+    }
 
-        if (this.aceDiv != null) {
-          this.aceDiv.removeEventListener(this.handleAceDivMouseLeave)
-          this.aceDiv.removeEventListener(this.handleAceDivMouseEnter)
-        }
+    // Grab the elements (pdfDiv, aceDiv) and set the "hover" event listeners.
+    // If elements are already defined, clear existing event listeners and do
+    // the process again (grab elements, set listeners).
+    this.setListeners = () => {
+      this.isOverAce = false
 
-        // Grab elements.
-        this.pdfDiv = document.querySelector('.pdfjs-viewer') // Grab the PDF div.
-        this.aceDiv = document.querySelector('.ace_content') // Also the editor.
+      // If elements aren't null, remove existing listeners.
+      if (this.pdfDiv != null) {
+        this.pdfDiv.removeEventListener(this.handlePdfDivMouseLeave)
+        this.pdfDiv.removeEventListener(this.handlePdfDivMouseEnter)
+      }
 
-        // Set hover-related listeners.
-        this.pdfDiv.addEventListener('mouseleave', this.handlePdfDivMouseLeave)
-        this.pdfDiv.addEventListener('mouseenter', this.handlePdfDivMouseEnter)
-        this.aceDiv.addEventListener('mouseleave', this.handleAceDivMouseLeave)
-        return this.aceDiv.addEventListener(
-          'mouseenter',
-          this.handleAceDivMouseEnter
-        )
+      if (this.aceDiv != null) {
+        this.aceDiv.removeEventListener(this.handleAceDivMouseLeave)
+        this.aceDiv.removeEventListener(this.handleAceDivMouseEnter)
       }
 
-      // Handler for wheel events on the PDF.
-      // If the pointer is over Ace, grab the event, prevent default behaviour
-      // and dispatch it to Ace.
-      this.dispatchToAce = e => {
-        if (this.isOverAce) {
-          // If this is logged, the problem just happened: the event arrived
-          // here (the PDF wheel handler), but it should've gone to Ace.
+      // Grab elements.
+      this.pdfDiv = document.querySelector('.pdfjs-viewer') // Grab the PDF div.
+      this.aceDiv = document.querySelector('.ace_content') // Also the editor.
 
-          // Small timeout - if we dispatch immediately, an exception is thrown.
-          window.setTimeout(() => {
-            // Dispatch the exact same event to Ace (this will keep values
-            // values e.g. `wheelDelta` consistent with user interaction).
-            return this.aceDiv.dispatchEvent(e)
-          }, 1)
+      // Set hover-related listeners.
+      this.pdfDiv.addEventListener('mouseleave', this.handlePdfDivMouseLeave)
+      this.pdfDiv.addEventListener('mouseenter', this.handlePdfDivMouseEnter)
+      this.aceDiv.addEventListener('mouseleave', this.handleAceDivMouseLeave)
+      return this.aceDiv.addEventListener(
+        'mouseenter',
+        this.handleAceDivMouseEnter
+      )
+    }
 
-          // Avoid scrolling the PDF, as we assume this was intended to the
-          // editor.
-          return e.preventDefault()
-        }
-      }
+    // Handler for wheel events on the PDF.
+    // If the pointer is over Ace, grab the event, prevent default behaviour
+    // and dispatch it to Ace.
+    this.dispatchToAce = e => {
+      if (this.isOverAce) {
+        // If this is logged, the problem just happened: the event arrived
+        // here (the PDF wheel handler), but it should've gone to Ace.
 
-      // "loaded" event is emitted from the pdfViewer controller $scope. This means
-      // that the previous PDF DOM element was destroyed and a new one is available,
-      // so we need to grab the elements and set the listeners again.
-      $scope.$on('loaded', () => {
-        return this.setListeners()
-      })
+        // Small timeout - if we dispatch immediately, an exception is thrown.
+        window.setTimeout(() => {
+          // Dispatch the exact same event to Ace (this will keep values
+          // values e.g. `wheelDelta` consistent with user interaction).
+          return this.aceDiv.dispatchEvent(e)
+        }, 1)
+
+        // Avoid scrolling the PDF, as we assume this was intended to the
+        // editor.
+        return e.preventDefault()
+      }
     }
-  })
+
+    // "loaded" event is emitted from the pdfViewer controller $scope. This means
+    // that the previous PDF DOM element was destroyed and a new one is available,
+    // so we need to grab the elements and set the listeners again.
+    $scope.$on('loaded', () => {
+      return this.setListeners()
+    })
+  }
 })

+ 27 - 27
services/web/frontend/js/ide/binary-files/BinaryFilesManager.js

@@ -9,32 +9,32 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['./controllers/BinaryFileController'], function() {
-  let BinaryFilesManager
-  return (BinaryFilesManager = class BinaryFilesManager {
-    constructor(ide, $scope) {
-      this.ide = ide
-      this.$scope = $scope
-      this.$scope.$on('entity:selected', (event, entity) => {
-        if (this.$scope.ui.view !== 'track-changes' && entity.type === 'file') {
-          return this.openFile(entity)
-        }
-      })
-    }
+import './controllers/BinaryFileController'
+let BinaryFilesManager
 
-    openFile(file) {
-      this.ide.fileTreeManager.selectEntity(file)
-      this.$scope.ui.view = 'file'
-      this.$scope.openFile = null
-      this.$scope.$apply()
-      return window.setTimeout(
-        () => {
-          this.$scope.openFile = file
-          return this.$scope.$apply()
-        },
-        0,
-        this
-      )
-    }
-  })
+export default (BinaryFilesManager = class BinaryFilesManager {
+  constructor(ide, $scope) {
+    this.ide = ide
+    this.$scope = $scope
+    this.$scope.$on('entity:selected', (event, entity) => {
+      if (this.$scope.ui.view !== 'track-changes' && entity.type === 'file') {
+        return this.openFile(entity)
+      }
+    })
+  }
+
+  openFile(file) {
+    this.ide.fileTreeManager.selectEntity(file)
+    this.$scope.ui.view = 'file'
+    this.$scope.openFile = null
+    this.$scope.$apply()
+    return window.setTimeout(
+      () => {
+        this.$scope.openFile = file
+        return this.$scope.$apply()
+      },
+      0,
+      this
+    )
+  }
 })

+ 211 - 212
services/web/frontend/js/ide/binary-files/controllers/BinaryFileController.js

@@ -1,220 +1,219 @@
-define(['../../../base'], App =>
-  App.controller('BinaryFileController', function(
-    $scope,
-    $rootScope,
-    $http,
-    $timeout,
-    $element,
-    ide,
-    waitFor
-  ) {
-    const MAX_FILE_SIZE = 2 * 1024 * 1024
-    const MAX_URL_LENGTH = 60
-    const FRONT_OF_URL_LENGTH = 35
-    const FILLER = '...'
-    const TAIL_OF_URL_LENGTH =
-      MAX_URL_LENGTH - FRONT_OF_URL_LENGTH - FILLER.length
-
-    const textExtensions = [
-      'tex',
-      'latex',
-      'sty',
-      'cls',
-      'bst',
-      'bib',
-      'bibtex',
-      'txt',
-      'tikz',
-      'rtex',
-      'md',
-      'asy',
-      'latexmkrc',
-      'lbx',
-      'bbx',
-      'cbx',
-      'm',
-      'lco',
-      'dtx',
-      'ins',
-      'ist',
-      'def',
-      'clo',
-      'ldf',
-      'rmd',
-      'lua'
-    ]
-    const imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
-    const previewableExtensions = []
-
-    const extension = file =>
-      file.name
-        .split('.')
-        .pop()
-        .toLowerCase()
-
-    $scope.isTextFile = () =>
-      textExtensions.indexOf(extension($scope.openFile)) > -1
-    $scope.isImageFile = () =>
-      imageExtensions.indexOf(extension($scope.openFile)) > -1
-    $scope.isPreviewableFile = () =>
-      previewableExtensions.indexOf(extension($scope.openFile)) > -1
-    $scope.isUnpreviewableFile = () =>
-      !$scope.isTextFile() &&
-      !$scope.isImageFile() &&
-      !$scope.isPreviewableFile()
-
-    $scope.textPreview = {
-      loading: false,
-      shouldShowDots: false,
-      error: false,
-      data: null
+import App from '../../../base'
+
+export default App.controller('BinaryFileController', function(
+  $scope,
+  $rootScope,
+  $http,
+  $timeout,
+  $element,
+  ide,
+  waitFor
+) {
+  const MAX_FILE_SIZE = 2 * 1024 * 1024
+  const MAX_URL_LENGTH = 60
+  const FRONT_OF_URL_LENGTH = 35
+  const FILLER = '...'
+  const TAIL_OF_URL_LENGTH =
+    MAX_URL_LENGTH - FRONT_OF_URL_LENGTH - FILLER.length
+
+  const textExtensions = [
+    'tex',
+    'latex',
+    'sty',
+    'cls',
+    'bst',
+    'bib',
+    'bibtex',
+    'txt',
+    'tikz',
+    'rtex',
+    'md',
+    'asy',
+    'latexmkrc',
+    'lbx',
+    'bbx',
+    'cbx',
+    'm',
+    'lco',
+    'dtx',
+    'ins',
+    'ist',
+    'def',
+    'clo',
+    'ldf',
+    'rmd',
+    'lua'
+  ]
+  const imageExtensions = ['png', 'jpg', 'jpeg', 'gif']
+  const previewableExtensions = []
+
+  const extension = file =>
+    file.name
+      .split('.')
+      .pop()
+      .toLowerCase()
+
+  $scope.isTextFile = () =>
+    textExtensions.indexOf(extension($scope.openFile)) > -1
+  $scope.isImageFile = () =>
+    imageExtensions.indexOf(extension($scope.openFile)) > -1
+  $scope.isPreviewableFile = () =>
+    previewableExtensions.indexOf(extension($scope.openFile)) > -1
+  $scope.isUnpreviewableFile = () =>
+    !$scope.isTextFile() && !$scope.isImageFile() && !$scope.isPreviewableFile()
+
+  $scope.textPreview = {
+    loading: false,
+    shouldShowDots: false,
+    error: false,
+    data: null
+  }
+
+  $scope.refreshing = false
+  $scope.refreshError = null
+
+  $scope.displayUrl = function(url) {
+    if (url == null) {
+      return
     }
-
-    $scope.refreshing = false
-    $scope.refreshError = null
-
-    $scope.displayUrl = function(url) {
-      if (url == null) {
-        return
-      }
-      if (url.length > MAX_URL_LENGTH) {
-        const front = url.slice(0, FRONT_OF_URL_LENGTH)
-        const tail = url.slice(url.length - TAIL_OF_URL_LENGTH)
-        return front + FILLER + tail
-      }
-      return url
-    }
-
-    $scope.refreshFile = function(file) {
-      $scope.refreshing = true
-      $scope.refreshError = null
-      ide.fileTreeManager
-        .refreshLinkedFile(file)
-        .then(function(response) {
-          const { data } = response
-          const newFileId = data.new_file_id
-          $timeout(
-            () =>
-              waitFor(() => ide.fileTreeManager.findEntityById(newFileId), 5000)
-                .then(newFile => ide.binaryFilesManager.openFile(newFile))
-                .catch(err => console.warn(err)),
-
-            0
-          )
-          $scope.refreshError = null
-        })
-        .catch(response => ($scope.refreshError = response.data))
-        .finally(() => {
-          $scope.refreshing = false
-          const provider = file.linkedFileData.provider
-          if (
-            provider === 'mendeley' ||
-            provider === 'zotero' ||
-            file.name.match(/^.*\.bib$/)
-          ) {
-            ide.$scope.$emit('references:should-reindex', {})
-          }
-        })
-    }
-
-    // Callback fired when the `img` tag fails to load,
-    // `failedLoad` used to show the "No Preview" message
-    $scope.failedLoad = false
-    window.sl_binaryFilePreviewError = () => {
-      $scope.failedLoad = true
-      $scope.$apply()
+    if (url.length > MAX_URL_LENGTH) {
+      const front = url.slice(0, FRONT_OF_URL_LENGTH)
+      const tail = url.slice(url.length - TAIL_OF_URL_LENGTH)
+      return front + FILLER + tail
     }
+    return url
+  }
 
-    // Callback fired when the `img` tag is done loading,
-    // `imgLoaded` is used to show the spinner gif while loading
-    $scope.imgLoaded = false
-    window.sl_binaryFilePreviewLoaded = () => {
-      $scope.imgLoaded = true
-      $scope.$apply()
-    }
-
-    if ($scope.isTextFile()) {
-      loadTextFilePreview()
-    }
-
-    function loadTextFilePreview() {
-      const url = `/project/${window.project_id}/file/${$scope.openFile.id}`
-      let truncated = false
-      displayPreviewLoading()
-      getFileSize(url)
-        .then(fileSize => {
-          const opts = {}
-          if (fileSize > MAX_FILE_SIZE) {
-            truncated = true
-            opts.maxSize = MAX_FILE_SIZE
-          }
-          return getFileContents(url, opts)
-        })
-        .then(contents => {
-          const displayedContents = truncated
-            ? truncateFileContents(contents)
-            : contents
-          displayPreview(displayedContents, truncated)
-        })
-        .catch(err => {
-          console.error(err)
-          displayPreviewError()
-        })
-    }
-
-    function getFileSize(url) {
-      return $http.head(url).then(response => {
-        const size = parseInt(response.headers('Content-Length'), 10)
-        if (isNaN(size)) {
-          throw new Error('Could not parse Content-Length header')
+  $scope.refreshFile = function(file) {
+    $scope.refreshing = true
+    $scope.refreshError = null
+    ide.fileTreeManager
+      .refreshLinkedFile(file)
+      .then(function(response) {
+        const { data } = response
+        const newFileId = data.new_file_id
+        $timeout(
+          () =>
+            waitFor(() => ide.fileTreeManager.findEntityById(newFileId), 5000)
+              .then(newFile => ide.binaryFilesManager.openFile(newFile))
+              .catch(err => console.warn(err)),
+
+          0
+        )
+        $scope.refreshError = null
+      })
+      .catch(response => ($scope.refreshError = response.data))
+      .finally(() => {
+        $scope.refreshing = false
+        const provider = file.linkedFileData.provider
+        if (
+          provider === 'mendeley' ||
+          provider === 'zotero' ||
+          file.name.match(/^.*\.bib$/)
+        ) {
+          ide.$scope.$emit('references:should-reindex', {})
         }
-        return size
       })
-    }
+  }
+
+  // Callback fired when the `img` tag fails to load,
+  // `failedLoad` used to show the "No Preview" message
+  $scope.failedLoad = false
+  window.sl_binaryFilePreviewError = () => {
+    $scope.failedLoad = true
+    $scope.$apply()
+  }
+
+  // Callback fired when the `img` tag is done loading,
+  // `imgLoaded` is used to show the spinner gif while loading
+  $scope.imgLoaded = false
+  window.sl_binaryFilePreviewLoaded = () => {
+    $scope.imgLoaded = true
+    $scope.$apply()
+  }
+
+  if ($scope.isTextFile()) {
+    loadTextFilePreview()
+  }
+
+  function loadTextFilePreview() {
+    const url = `/project/${window.project_id}/file/${$scope.openFile.id}`
+    let truncated = false
+    displayPreviewLoading()
+    getFileSize(url)
+      .then(fileSize => {
+        const opts = {}
+        if (fileSize > MAX_FILE_SIZE) {
+          truncated = true
+          opts.maxSize = MAX_FILE_SIZE
+        }
+        return getFileContents(url, opts)
+      })
+      .then(contents => {
+        const displayedContents = truncated
+          ? truncateFileContents(contents)
+          : contents
+        displayPreview(displayedContents, truncated)
+      })
+      .catch(err => {
+        console.error(err)
+        displayPreviewError()
+      })
+  }
 
-    function getFileContents(url, opts = {}) {
-      const { maxSize } = opts
-      if (maxSize != null) {
-        url += `?range=0-${maxSize}`
+  function getFileSize(url) {
+    return $http.head(url).then(response => {
+      const size = parseInt(response.headers('Content-Length'), 10)
+      if (isNaN(size)) {
+        throw new Error('Could not parse Content-Length header')
       }
-      return $http
-        .get(url, {
-          transformResponse: null // Don't parse JSON
-        })
-        .then(response => {
-          return response.data
-        })
-    }
-
-    function truncateFileContents(contents) {
-      return contents.replace(/\n.*$/, '')
-    }
-
-    function displayPreviewLoading() {
-      $scope.textPreview.data = null
-      $scope.textPreview.loading = true
-      $scope.textPreview.shouldShowDots = false
-      $scope.$apply()
+      return size
+    })
+  }
+
+  function getFileContents(url, opts = {}) {
+    const { maxSize } = opts
+    if (maxSize != null) {
+      url += `?range=0-${maxSize}`
     }
-
-    function displayPreview(contents, truncated) {
-      $scope.textPreview.error = false
-      $scope.textPreview.data = contents
-      $scope.textPreview.shouldShowDots = truncated
-      $timeout(setPreviewHeight, 0)
-    }
-
-    function displayPreviewError() {
-      $scope.textPreview.error = true
-      $scope.textPreview.loading = false
-    }
-
-    function setPreviewHeight() {
-      const $preview = $element.find('.text-preview .scroll-container')
-      const $header = $element.find('.binary-file-header')
-      const maxHeight = $element.height() - $header.height() - 14 // borders + margin
-      $preview.css({ 'max-height': maxHeight })
-      // Don't show the preview until we've set the height, otherwise we jump around
-      $scope.textPreview.loading = false
-    }
-  }))
+    return $http
+      .get(url, {
+        transformResponse: null // Don't parse JSON
+      })
+      .then(response => {
+        return response.data
+      })
+  }
+
+  function truncateFileContents(contents) {
+    return contents.replace(/\n.*$/, '')
+  }
+
+  function displayPreviewLoading() {
+    $scope.textPreview.data = null
+    $scope.textPreview.loading = true
+    $scope.textPreview.shouldShowDots = false
+    $scope.$apply()
+  }
+
+  function displayPreview(contents, truncated) {
+    $scope.textPreview.error = false
+    $scope.textPreview.data = contents
+    $scope.textPreview.shouldShowDots = truncated
+    $timeout(setPreviewHeight, 0)
+  }
+
+  function displayPreviewError() {
+    $scope.textPreview.error = true
+    $scope.textPreview.loading = false
+  }
+
+  function setPreviewHeight() {
+    const $preview = $element.find('.text-preview .scroll-container')
+    const $header = $element.find('.binary-file-header')
+    const maxHeight = $element.height() - $header.height() - 14 // borders + margin
+    $preview.css({ 'max-height': maxHeight })
+    // Don't show the preview until we've set the height, otherwise we jump around
+    $scope.textPreview.loading = false
+  }
+})

+ 52 - 51
services/web/frontend/js/ide/chat/controllers/ChatButtonController.js

@@ -11,67 +11,68 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('ChatButtonController', function($scope, ide) {
-    let clearNewMessageNotification
-    $scope.toggleChat = function() {
-      $scope.ui.chatOpen = !$scope.ui.chatOpen
-      return $scope.resetUnreadMessages()
-    }
+import App from '../../../base'
+
+export default App.controller('ChatButtonController', function($scope, ide) {
+  let clearNewMessageNotification
+  $scope.toggleChat = function() {
+    $scope.ui.chatOpen = !$scope.ui.chatOpen
+    return $scope.resetUnreadMessages()
+  }
 
-    $scope.unreadMessages = 0
-    $scope.resetUnreadMessages = () => ($scope.unreadMessages = 0)
+  $scope.unreadMessages = 0
+  $scope.resetUnreadMessages = () => ($scope.unreadMessages = 0)
 
-    $scope.$on('chat:resetUnreadMessages', e => $scope.resetUnreadMessages())
+  $scope.$on('chat:resetUnreadMessages', e => $scope.resetUnreadMessages())
 
-    $scope.$on('chat:newMessage', function(e, message) {
-      if (message != null) {
-        if (
-          __guard__(message != null ? message.user : undefined, x => x.id) !==
-          ide.$scope.user.id
-        ) {
-          if (!$scope.ui.chatOpen) {
-            $scope.unreadMessages += 1
-          }
-          return flashTitle()
+  $scope.$on('chat:newMessage', function(e, message) {
+    if (message != null) {
+      if (
+        __guard__(message != null ? message.user : undefined, x => x.id) !==
+        ide.$scope.user.id
+      ) {
+        if (!$scope.ui.chatOpen) {
+          $scope.unreadMessages += 1
         }
+        return flashTitle()
       }
-    })
+    }
+  })
 
-    let focussed = true
-    let newMessageNotificationTimeout = null
-    let originalTitle = null
-    $(window).on('focus', function() {
-      clearNewMessageNotification()
-      return (focussed = true)
-    })
-    $(window).on('blur', () => (focussed = false))
+  let focussed = true
+  let newMessageNotificationTimeout = null
+  let originalTitle = null
+  $(window).on('focus', function() {
+    clearNewMessageNotification()
+    return (focussed = true)
+  })
+  $(window).on('blur', () => (focussed = false))
 
-    var flashTitle = function() {
-      if (!focussed && newMessageNotificationTimeout == null) {
-        let changeTitle
-        if (!originalTitle) {
-          originalTitle = window.document.title
-        }
-        return (changeTitle = () => {
-          if (window.document.title === originalTitle) {
-            window.document.title = 'New Message'
-          } else {
-            window.document.title = originalTitle
-          }
-          return (newMessageNotificationTimeout = setTimeout(changeTitle, 800))
-        })()
+  var flashTitle = function() {
+    if (!focussed && newMessageNotificationTimeout == null) {
+      let changeTitle
+      if (!originalTitle) {
+        originalTitle = window.document.title
       }
+      return (changeTitle = () => {
+        if (window.document.title === originalTitle) {
+          window.document.title = 'New Message'
+        } else {
+          window.document.title = originalTitle
+        }
+        return (newMessageNotificationTimeout = setTimeout(changeTitle, 800))
+      })()
     }
+  }
 
-    return (clearNewMessageNotification = function() {
-      clearTimeout(newMessageNotificationTimeout)
-      newMessageNotificationTimeout = null
-      if (originalTitle != null) {
-        return (window.document.title = originalTitle)
-      }
-    })
-  }))
+  return (clearNewMessageNotification = function() {
+    clearTimeout(newMessageNotificationTimeout)
+    newMessageNotificationTimeout = null
+    if (originalTitle != null) {
+      return (window.document.title = originalTitle)
+    }
+  })
+})
 
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null

+ 33 - 31
services/web/frontend/js/ide/chat/controllers/ChatController.js

@@ -10,41 +10,43 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base', '../services/chatMessages'], App =>
-  App.controller('ChatController', function(
-    $scope,
-    chatMessages,
-    ide,
-    $location
-  ) {
-    $scope.chat = chatMessages.state
+import App from '../../../base'
+import '../services/chatMessages'
 
-    $scope.$watch(
-      'chat.messages',
-      function(messages) {
-        if (messages != null) {
-          return $scope.$emit('updateScrollPosition')
-        }
-      },
-      true
-    ) // Deep watch
+export default App.controller('ChatController', function(
+  $scope,
+  chatMessages,
+  ide,
+  $location
+) {
+  $scope.chat = chatMessages.state
 
-    $scope.$on('layout:chat:resize', () => $scope.$emit('updateScrollPosition'))
-
-    $scope.$watch('chat.newMessage', function(message) {
-      if (message != null) {
-        return ide.$scope.$broadcast('chat:newMessage', message)
+  $scope.$watch(
+    'chat.messages',
+    function(messages) {
+      if (messages != null) {
+        return $scope.$emit('updateScrollPosition')
       }
-    })
+    },
+    true
+  ) // Deep watch
 
-    $scope.resetUnreadMessages = () =>
-      ide.$scope.$broadcast('chat:resetUnreadMessages')
+  $scope.$on('layout:chat:resize', () => $scope.$emit('updateScrollPosition'))
 
-    $scope.sendMessage = function() {
-      const message = $scope.newMessageContent
-      $scope.newMessageContent = ''
-      return chatMessages.sendMessage(message)
+  $scope.$watch('chat.newMessage', function(message) {
+    if (message != null) {
+      return ide.$scope.$broadcast('chat:newMessage', message)
     }
+  })
+
+  $scope.resetUnreadMessages = () =>
+    ide.$scope.$broadcast('chat:resetUnreadMessages')
+
+  $scope.sendMessage = function() {
+    const message = $scope.newMessageContent
+    $scope.newMessageContent = ''
+    return chatMessages.sendMessage(message)
+  }
 
-    return ($scope.loadMoreMessages = () => chatMessages.loadMoreMessages())
-  }))
+  return ($scope.loadMoreMessages = () => chatMessages.loadMoreMessages())
+})

+ 41 - 39
services/web/frontend/js/ide/chat/controllers/ChatMessageController.js

@@ -10,47 +10,49 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base', '../../colors/ColorManager'], (App, ColorManager) =>
-  App.controller('ChatMessageController', function($scope, ide) {
-    const hslColorConfigs = {
-      borderSaturation:
-        (window.uiConfig != null
-          ? window.uiConfig.chatMessageBorderSaturation
-          : undefined) || '70%',
-      borderLightness:
-        (window.uiConfig != null
-          ? window.uiConfig.chatMessageBorderLightness
-          : undefined) || '70%',
-      bgSaturation:
-        (window.uiConfig != null
-          ? window.uiConfig.chatMessageBgSaturation
-          : undefined) || '60%',
-      bgLightness:
-        (window.uiConfig != null
-          ? window.uiConfig.chatMessageBgLightness
-          : undefined) || '97%'
-    }
+import App from '../../../base'
+import ColorManager from '../../colors/ColorManager'
+
+export default App.controller('ChatMessageController', function($scope, ide) {
+  const hslColorConfigs = {
+    borderSaturation:
+      (window.uiConfig != null
+        ? window.uiConfig.chatMessageBorderSaturation
+        : undefined) || '70%',
+    borderLightness:
+      (window.uiConfig != null
+        ? window.uiConfig.chatMessageBorderLightness
+        : undefined) || '70%',
+    bgSaturation:
+      (window.uiConfig != null
+        ? window.uiConfig.chatMessageBgSaturation
+        : undefined) || '60%',
+    bgLightness:
+      (window.uiConfig != null
+        ? window.uiConfig.chatMessageBgLightness
+        : undefined) || '97%'
+  }
 
-    const hue = function(user) {
-      if (user == null) {
-        return 0
-      } else {
-        return ColorManager.getHueForUserId(user.id)
-      }
+  const hue = function(user) {
+    if (user == null) {
+      return 0
+    } else {
+      return ColorManager.getHueForUserId(user.id)
     }
+  }
 
-    $scope.getMessageStyle = user => ({
-      'border-color': `hsl(${hue(user)}, ${hslColorConfigs.borderSaturation}, ${
-        hslColorConfigs.borderLightness
-      })`,
-      'background-color': `hsl(${hue(user)}, ${hslColorConfigs.bgSaturation}, ${
-        hslColorConfigs.bgLightness
-      })`
-    })
+  $scope.getMessageStyle = user => ({
+    'border-color': `hsl(${hue(user)}, ${hslColorConfigs.borderSaturation}, ${
+      hslColorConfigs.borderLightness
+    })`,
+    'background-color': `hsl(${hue(user)}, ${hslColorConfigs.bgSaturation}, ${
+      hslColorConfigs.bgLightness
+    })`
+  })
 
-    return ($scope.getArrowStyle = user => ({
-      'border-color': `hsl(${hue(user)}, ${hslColorConfigs.borderSaturation}, ${
-        hslColorConfigs.borderLightness
-      })`
-    }))
+  return ($scope.getArrowStyle = user => ({
+    'border-color': `hsl(${hue(user)}, ${hslColorConfigs.borderSaturation}, ${
+      hslColorConfigs.borderLightness
+    })`
   }))
+})

+ 5 - 7
services/web/frontend/js/ide/chat/index.js

@@ -2,10 +2,8 @@
 */
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
-define([
-  './controllers/ChatButtonController',
-  './controllers/ChatController',
-  './controllers/ChatMessageController',
-  '../../directives/mathjax',
-  '../../filters/wrapLongWords'
-], function() {})
+import './controllers/ChatButtonController'
+import './controllers/ChatController'
+import './controllers/ChatMessageController'
+import '../../directives/mathjax'
+import '../../filters/wrapLongWords'

+ 123 - 121
services/web/frontend/js/ide/chat/services/chatMessages.js

@@ -15,145 +15,147 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.factory('chatMessages', function($http, ide) {
-    const MESSAGES_URL = `/project/${ide.project_id}/messages`
-    const MESSAGE_LIMIT = 50
-    const CONNECTED_USER_URL = `/project/${ide.project_id}/connected_users`
+import App from '../../../base'
 
-    const chat = {
-      state: {
-        messages: [],
-        loading: false,
-        atEnd: false,
-        errored: false,
-        nextBeforeTimestamp: null,
-        newMessage: null
-      }
+export default App.factory('chatMessages', function($http, ide) {
+  const MESSAGES_URL = `/project/${ide.project_id}/messages`
+  const MESSAGE_LIMIT = 50
+  const CONNECTED_USER_URL = `/project/${ide.project_id}/connected_users`
+
+  const chat = {
+    state: {
+      messages: [],
+      loading: false,
+      atEnd: false,
+      errored: false,
+      nextBeforeTimestamp: null,
+      newMessage: null
     }
+  }
 
-    let justSent = false
-    ide.socket.on('new-chat-message', message => {
-      if (
-        __guard__(message != null ? message.user : undefined, x => x.id) ===
-          ide.$scope.user.id &&
-        justSent
-      ) {
-        // Nothing to do
-      } else {
-        ide.$scope.$apply(() => appendMessage(message))
-      }
-      return (justSent = false)
-    })
+  let justSent = false
+  ide.socket.on('new-chat-message', message => {
+    if (
+      __guard__(message != null ? message.user : undefined, x => x.id) ===
+        ide.$scope.user.id &&
+      justSent
+    ) {
+      // Nothing to do
+    } else {
+      ide.$scope.$apply(() => appendMessage(message))
+    }
+    return (justSent = false)
+  })
 
-    chat.sendMessage = function(message) {
-      const body = {
-        content: message,
-        _csrf: window.csrfToken
-      }
-      justSent = true
-      appendMessage({
-        user: ide.$scope.user,
-        content: message,
-        timestamp: Date.now()
-      })
-      return $http.post(MESSAGES_URL, body)
+  chat.sendMessage = function(message) {
+    const body = {
+      content: message,
+      _csrf: window.csrfToken
     }
+    justSent = true
+    appendMessage({
+      user: ide.$scope.user,
+      content: message,
+      timestamp: Date.now()
+    })
+    return $http.post(MESSAGES_URL, body)
+  }
 
-    chat.loadMoreMessages = function() {
-      if (chat.state.atEnd) {
-        return
-      }
-      if (chat.state.errored) {
-        return
-      }
-      let url = MESSAGES_URL + `?limit=${MESSAGE_LIMIT}`
-      if (chat.state.nextBeforeTimestamp != null) {
-        url += `&before=${chat.state.nextBeforeTimestamp}`
+  chat.loadMoreMessages = function() {
+    if (chat.state.atEnd) {
+      return
+    }
+    if (chat.state.errored) {
+      return
+    }
+    let url = MESSAGES_URL + `?limit=${MESSAGE_LIMIT}`
+    if (chat.state.nextBeforeTimestamp != null) {
+      url += `&before=${chat.state.nextBeforeTimestamp}`
+    }
+    chat.state.loading = true
+    return $http.get(url).then(function(response) {
+      const messages = response.data != null ? response.data : []
+      chat.state.loading = false
+      if (messages.length < MESSAGE_LIMIT) {
+        chat.state.atEnd = true
       }
-      chat.state.loading = true
-      return $http.get(url).then(function(response) {
-        const messages = response.data != null ? response.data : []
-        chat.state.loading = false
-        if (messages.length < MESSAGE_LIMIT) {
-          chat.state.atEnd = true
+      if (messages.reverse == null) {
+        if (typeof Raven !== 'undefined' && Raven !== null) {
+          Raven.captureException(
+            new Error(`messages has no reverse property ${typeof messages}`)
+          )
         }
-        if (messages.reverse == null) {
-          if (typeof Raven !== 'undefined' && Raven !== null) {
-            Raven.captureException(
-              new Error(`messages has no reverse property ${typeof messages}`)
-            )
-          }
-        }
-        if (typeof messages.reverse !== 'function') {
-          if (typeof Raven !== 'undefined' && Raven !== null) {
-            Raven.captureException(
-              new Error(
-                `messages.reverse not a function ${typeof messages.reverse} ${typeof messages}`
-              )
+      }
+      if (typeof messages.reverse !== 'function') {
+        if (typeof Raven !== 'undefined' && Raven !== null) {
+          Raven.captureException(
+            new Error(
+              `messages.reverse not a function ${typeof messages.reverse} ${typeof messages}`
             )
-          }
-          return (chat.state.errored = true)
-        } else {
-          messages.reverse()
-          prependMessages(messages)
-          return (chat.state.nextBeforeTimestamp =
-            chat.state.messages[0] != null
-              ? chat.state.messages[0].timestamp
-              : undefined)
+          )
         }
-      })
-    }
-
-    const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
-
-    const prependMessage = function(message) {
-      const firstMessage = chat.state.messages[0]
-      const shouldGroup =
-        firstMessage != null &&
-        firstMessage.user.id ===
-          __guard__(message != null ? message.user : undefined, x => x.id) &&
-        firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
-      if (shouldGroup) {
-        firstMessage.timestamp = message.timestamp
-        return firstMessage.contents.unshift(message.content)
+        return (chat.state.errored = true)
       } else {
-        return chat.state.messages.unshift({
-          user: message.user,
-          timestamp: message.timestamp,
-          contents: [message.content]
-        })
+        messages.reverse()
+        prependMessages(messages)
+        return (chat.state.nextBeforeTimestamp =
+          chat.state.messages[0] != null
+            ? chat.state.messages[0].timestamp
+            : undefined)
       }
+    })
+  }
+
+  const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
+
+  const prependMessage = function(message) {
+    const firstMessage = chat.state.messages[0]
+    const shouldGroup =
+      firstMessage != null &&
+      firstMessage.user.id ===
+        __guard__(message != null ? message.user : undefined, x => x.id) &&
+      firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
+    if (shouldGroup) {
+      firstMessage.timestamp = message.timestamp
+      return firstMessage.contents.unshift(message.content)
+    } else {
+      return chat.state.messages.unshift({
+        user: message.user,
+        timestamp: message.timestamp,
+        contents: [message.content]
+      })
     }
+  }
 
-    var prependMessages = messages =>
-      Array.from(messages.slice(0).reverse()).map(message =>
-        prependMessage(message)
-      )
+  var prependMessages = messages =>
+    Array.from(messages.slice(0).reverse()).map(message =>
+      prependMessage(message)
+    )
 
-    var appendMessage = function(message) {
-      chat.state.newMessage = message
+  var appendMessage = function(message) {
+    chat.state.newMessage = message
 
-      const lastMessage = chat.state.messages[chat.state.messages.length - 1]
-      const shouldGroup =
-        lastMessage != null &&
-        lastMessage.user.id ===
-          __guard__(message != null ? message.user : undefined, x => x.id) &&
-        message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
-      if (shouldGroup) {
-        lastMessage.timestamp = message.timestamp
-        return lastMessage.contents.push(message.content)
-      } else {
-        return chat.state.messages.push({
-          user: message.user,
-          timestamp: message.timestamp,
-          contents: [message.content]
-        })
-      }
+    const lastMessage = chat.state.messages[chat.state.messages.length - 1]
+    const shouldGroup =
+      lastMessage != null &&
+      lastMessage.user.id ===
+        __guard__(message != null ? message.user : undefined, x => x.id) &&
+      message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
+    if (shouldGroup) {
+      lastMessage.timestamp = message.timestamp
+      return lastMessage.contents.push(message.content)
+    } else {
+      return chat.state.messages.push({
+        user: message.user,
+        timestamp: message.timestamp,
+        contents: [message.content]
+      })
     }
+  }
+
+  return chat
+})
 
-    return chat
-  }))
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null
     ? transform(value)

+ 11 - 10
services/web/frontend/js/ide/clone/controllers/CloneProjectController.js

@@ -8,13 +8,14 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller(
-    'CloneProjectController',
-    ($scope, $modal) =>
-      ($scope.openCloneProjectModal = () =>
-        $modal.open({
-          templateUrl: 'cloneProjectModalTemplate',
-          controller: 'CloneProjectModalController'
-        }))
-  ))
+import App from '../../../base'
+
+export default App.controller(
+  'CloneProjectController',
+  ($scope, $modal) =>
+    ($scope.openCloneProjectModal = () =>
+      $modal.open({
+        templateUrl: 'cloneProjectModalTemplate',
+        controller: 'CloneProjectModalController'
+      }))
+)

+ 42 - 41
services/web/frontend/js/ide/clone/controllers/CloneProjectModalController.js

@@ -9,48 +9,49 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('CloneProjectModalController', function(
-    $scope,
-    $modalInstance,
-    $timeout,
-    $http,
-    ide
-  ) {
-    $scope.inputs = { projectName: ide.$scope.project.name + ' (Copy)' }
-    $scope.state = {
-      inflight: false,
-      error: false
-    }
+import App from '../../../base'
 
-    $modalInstance.opened.then(() =>
-      $timeout(() => $scope.$broadcast('open'), 200)
-    )
+export default App.controller('CloneProjectModalController', function(
+  $scope,
+  $modalInstance,
+  $timeout,
+  $http,
+  ide
+) {
+  $scope.inputs = { projectName: ide.$scope.project.name + ' (Copy)' }
+  $scope.state = {
+    inflight: false,
+    error: false
+  }
 
-    const cloneProject = cloneName =>
-      $http.post(`/project/${ide.$scope.project._id}/clone`, {
-        _csrf: window.csrfToken,
-        projectName: cloneName
-      })
+  $modalInstance.opened.then(() =>
+    $timeout(() => $scope.$broadcast('open'), 200)
+  )
+
+  const cloneProject = cloneName =>
+    $http.post(`/project/${ide.$scope.project._id}/clone`, {
+      _csrf: window.csrfToken,
+      projectName: cloneName
+    })
 
-    $scope.clone = function() {
-      $scope.state.inflight = true
-      $scope.state.error = false
-      return cloneProject($scope.inputs.projectName)
-        .then(function(response) {
-          const { data } = response
-          return (window.location = `/project/${data.project_id}`)
-        })
-        .catch(function(response) {
-          const { data, status } = response
-          $scope.state.inflight = false
-          if (status === 400) {
-            return ($scope.state.error = { message: data })
-          } else {
-            return ($scope.state.error = true)
-          }
-        })
-    }
+  $scope.clone = function() {
+    $scope.state.inflight = true
+    $scope.state.error = false
+    return cloneProject($scope.inputs.projectName)
+      .then(function(response) {
+        const { data } = response
+        return (window.location = `/project/${data.project_id}`)
+      })
+      .catch(function(response) {
+        const { data, status } = response
+        $scope.state.inflight = false
+        if (status === 400) {
+          return ($scope.state.error = { message: data })
+        } else {
+          return ($scope.state.error = true)
+        }
+      })
+  }
 
-    return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
-  }))
+  return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
+})

+ 2 - 4
services/web/frontend/js/ide/clone/index.js

@@ -2,7 +2,5 @@
 */
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
-define([
-  './controllers/CloneProjectController',
-  './controllers/CloneProjectModalController'
-], function() {})
+import './controllers/CloneProjectController'
+import './controllers/CloneProjectModalController'

+ 34 - 35
services/web/frontend/js/ide/cobranding/CobrandingDataService.js

@@ -11,50 +11,49 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../base'], function(App) {
-  const _cobrandingData = window.brandVariation
+import App from '../../base'
+const _cobrandingData = window.brandVariation
 
-  return App.factory('CobrandingDataService', function() {
-    const isProjectCobranded = () => _cobrandingData != null
+export default App.factory('CobrandingDataService', function() {
+  const isProjectCobranded = () => _cobrandingData != null
 
-    const getLogoImgUrl = () =>
-      _cobrandingData != null ? _cobrandingData.logo_url : undefined
+  const getLogoImgUrl = () =>
+    _cobrandingData != null ? _cobrandingData.logo_url : undefined
 
-    const getSubmitBtnHtml = () =>
-      _cobrandingData != null ? _cobrandingData.submit_button_html : undefined
+  const getSubmitBtnHtml = () =>
+    _cobrandingData != null ? _cobrandingData.submit_button_html : undefined
 
-    const getBrandVariationName = () =>
-      _cobrandingData != null ? _cobrandingData.name : undefined
+  const getBrandVariationName = () =>
+    _cobrandingData != null ? _cobrandingData.name : undefined
 
-    const getBrandVariationHomeUrl = () =>
-      _cobrandingData != null ? _cobrandingData.home_url : undefined
+  const getBrandVariationHomeUrl = () =>
+    _cobrandingData != null ? _cobrandingData.home_url : undefined
 
-    const getPublishGuideHtml = () =>
-      _cobrandingData != null ? _cobrandingData.publish_guide_html : undefined
+  const getPublishGuideHtml = () =>
+    _cobrandingData != null ? _cobrandingData.publish_guide_html : undefined
 
-    const getPartner = () =>
-      _cobrandingData != null ? _cobrandingData.partner : undefined
+  const getPartner = () =>
+    _cobrandingData != null ? _cobrandingData.partner : undefined
 
-    const hasBrandedMenu = () =>
-      _cobrandingData != null ? _cobrandingData.branded_menu : undefined
+  const hasBrandedMenu = () =>
+    _cobrandingData != null ? _cobrandingData.branded_menu : undefined
 
-    const getBrandId = () =>
-      _cobrandingData != null ? _cobrandingData.brand_id : undefined
+  const getBrandId = () =>
+    _cobrandingData != null ? _cobrandingData.brand_id : undefined
 
-    const getBrandVariationId = () =>
-      _cobrandingData != null ? _cobrandingData.id : undefined
+  const getBrandVariationId = () =>
+    _cobrandingData != null ? _cobrandingData.id : undefined
 
-    return {
-      isProjectCobranded,
-      getLogoImgUrl,
-      getSubmitBtnHtml,
-      getBrandVariationName,
-      getBrandVariationHomeUrl,
-      getPublishGuideHtml,
-      getPartner,
-      hasBrandedMenu,
-      getBrandId,
-      getBrandVariationId
-    }
-  })
+  return {
+    isProjectCobranded,
+    getLogoImgUrl,
+    getSubmitBtnHtml,
+    getBrandVariationName,
+    getBrandVariationHomeUrl,
+    getPublishGuideHtml,
+    getPartner,
+    hasBrandedMenu,
+    getBrandId,
+    getBrandVariationId
+  }
 })

+ 65 - 65
services/web/frontend/js/ide/colors/ColorManager.js

@@ -13,78 +13,78 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['crypto-js/md5'], function(CryptoJS) {
-  let ColorManager
-  return (ColorManager = {
-    getColorScheme(hue, element) {
-      if (this.isDarkTheme(element)) {
-        return {
-          cursor: `hsl(${hue}, 70%, 50%)`,
-          labelBackgroundColor: `hsl(${hue}, 70%, 50%)`,
-          highlightBackgroundColor: `hsl(${hue}, 100%, 28%);`,
-          strikeThroughBackgroundColor: `hsl(${hue}, 100%, 20%);`,
-          strikeThroughForegroundColor: `hsl(${hue}, 100%, 60%);`
-        }
-      } else {
-        return {
-          cursor: `hsl(${hue}, 70%, 50%)`,
-          labelBackgroundColor: `hsl(${hue}, 70%, 50%)`,
-          highlightBackgroundColor: `hsl(${hue}, 70%, 85%);`,
-          strikeThroughBackgroundColor: `hsl(${hue}, 70%, 95%);`,
-          strikeThroughForegroundColor: `hsl(${hue}, 70%, 40%);`
-        }
-      }
-    },
-
-    isDarkTheme(element) {
-      const rgb = element.find('.ace_editor').css('background-color')
-      let [m, r, g, b] = Array.from(
-        rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
-      )
-      r = parseInt(r, 10)
-      g = parseInt(g, 10)
-      b = parseInt(b, 10)
-      return r + g + b < 3 * 128
-    },
+import CryptoJS from 'crypto-js/md5'
+let ColorManager
 
-    ANONYMOUS_HUE: 100,
-    OWN_HUE: 200, // We will always appear as this color to ourselves
-    OWN_HUE_BLOCKED_SIZE: 20, // no other user should havea HUE in this range
-    TOTAL_HUES: 360, // actually 361, but 360 for legacy reasons
-    getHueForUserId(user_id) {
-      if (user_id == null || user_id === 'anonymous-user') {
-        return this.ANONYMOUS_HUE
+export default (ColorManager = {
+  getColorScheme(hue, element) {
+    if (this.isDarkTheme(element)) {
+      return {
+        cursor: `hsl(${hue}, 70%, 50%)`,
+        labelBackgroundColor: `hsl(${hue}, 70%, 50%)`,
+        highlightBackgroundColor: `hsl(${hue}, 100%, 28%);`,
+        strikeThroughBackgroundColor: `hsl(${hue}, 100%, 20%);`,
+        strikeThroughForegroundColor: `hsl(${hue}, 100%, 60%);`
       }
-
-      if (window.user.id === user_id) {
-        return this.OWN_HUE
+    } else {
+      return {
+        cursor: `hsl(${hue}, 70%, 50%)`,
+        labelBackgroundColor: `hsl(${hue}, 70%, 50%)`,
+        highlightBackgroundColor: `hsl(${hue}, 70%, 85%);`,
+        strikeThroughBackgroundColor: `hsl(${hue}, 70%, 95%);`,
+        strikeThroughForegroundColor: `hsl(${hue}, 70%, 40%);`
       }
+    }
+  },
 
-      let hue = this.getHueForId(user_id)
+  isDarkTheme(element) {
+    const rgb = element.find('.ace_editor').css('background-color')
+    let [m, r, g, b] = Array.from(
+      rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
+    )
+    r = parseInt(r, 10)
+    g = parseInt(g, 10)
+    b = parseInt(b, 10)
+    return r + g + b < 3 * 128
+  },
 
-      // if `hue` is within `OWN_HUE_BLOCKED_SIZE` degrees of the personal hue
-      // (`OWN_HUE`), shift `hue` to the end of available hues by adding
-      if (
-        hue > this.OWN_HUE - this.OWN_HUE_BLOCKED_SIZE &&
-        hue < this.OWN_HUE + this.OWN_HUE_BLOCKED_SIZE
-      ) {
-        hue = hue - this.OWN_HUE // `hue` now at 0 +/- `OWN_HUE_BLOCKED_SIZE`
-        hue = hue + this.TOTAL_HUES - this.OWN_HUE_BLOCKED_SIZE
-      }
+  ANONYMOUS_HUE: 100,
+  OWN_HUE: 200, // We will always appear as this color to ourselves
+  OWN_HUE_BLOCKED_SIZE: 20, // no other user should havea HUE in this range
+  TOTAL_HUES: 360, // actually 361, but 360 for legacy reasons
+  getHueForUserId(user_id) {
+    if (user_id == null || user_id === 'anonymous-user') {
+      return this.ANONYMOUS_HUE
+    }
 
-      return hue
-    },
+    if (window.user.id === user_id) {
+      return this.OWN_HUE
+    }
 
-    getHueForTagId(tag_id) {
-      return this.getHueForId(tag_id)
-    },
+    let hue = this.getHueForId(user_id)
 
-    getHueForId(id) {
-      const hash = CryptoJS(id)
-      let hue =
-        parseInt(hash.toString().slice(0, 8), 16) %
-        (this.TOTAL_HUES - this.OWN_HUE_BLOCKED_SIZE * 2)
-      return hue
+    // if `hue` is within `OWN_HUE_BLOCKED_SIZE` degrees of the personal hue
+    // (`OWN_HUE`), shift `hue` to the end of available hues by adding
+    if (
+      hue > this.OWN_HUE - this.OWN_HUE_BLOCKED_SIZE &&
+      hue < this.OWN_HUE + this.OWN_HUE_BLOCKED_SIZE
+    ) {
+      hue = hue - this.OWN_HUE // `hue` now at 0 +/- `OWN_HUE_BLOCKED_SIZE`
+      hue = hue + this.TOTAL_HUES - this.OWN_HUE_BLOCKED_SIZE
     }
-  })
+
+    return hue
+  },
+
+  getHueForTagId(tag_id) {
+    return this.getHueForId(tag_id)
+  },
+
+  getHueForId(id) {
+    const hash = CryptoJS(id)
+    let hue =
+      parseInt(hash.toString().slice(0, 8), 16) %
+      (this.TOTAL_HUES - this.OWN_HUE_BLOCKED_SIZE * 2)
+    return hue
+  }
 })

+ 546 - 547
services/web/frontend/js/ide/connection/ConnectionManager.js

@@ -10,635 +10,634 @@
 
 /* global io */
 
-define(['./SocketIoShim'], function(SocketIoShim) {
-  let ConnectionManager
-  const ONEHOUR = 1000 * 60 * 60
+import SocketIoShim from './SocketIoShim'
+let ConnectionManager
+const ONEHOUR = 1000 * 60 * 60
 
-  return (ConnectionManager = (function() {
-    ConnectionManager = class ConnectionManager {
-      static initClass() {
-        this.prototype.disconnectAfterMs = ONEHOUR * 24
+export default (ConnectionManager = (function() {
+  ConnectionManager = class ConnectionManager {
+    static initClass() {
+      this.prototype.disconnectAfterMs = ONEHOUR * 24
 
-        this.prototype.lastUserAction = new Date()
+      this.prototype.lastUserAction = new Date()
 
-        this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now"
-        this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000
+      this.prototype.MIN_RETRY_INTERVAL = 1000 // ms, rate limit on reconnects for user clicking "try now"
+      this.prototype.BACKGROUND_RETRY_INTERVAL = 5 * 1000
 
-        this.prototype.JOIN_PROJECT_RETRY_INTERVAL = 5000
-        this.prototype.JOIN_PROJECT_MAX_RETRY_INTERVAL = 60000
-        this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms
-        this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000
-      }
+      this.prototype.JOIN_PROJECT_RETRY_INTERVAL = 5000
+      this.prototype.JOIN_PROJECT_MAX_RETRY_INTERVAL = 60000
+      this.prototype.RECONNECT_GRACEFULLY_RETRY_INTERVAL = 5000 // ms
+      this.prototype.MAX_RECONNECT_GRACEFULLY_INTERVAL = 45 * 1000
+    }
 
-      constructor(ide, $scope) {
-        this.ide = ide
-        this.$scope = $scope
-        this.wsUrl = ide.wsUrl || null // websocket url (if defined)
-        if (typeof io === 'undefined' || io === null) {
-          if (this.wsUrl && !window.location.href.match(/ws=fallback/)) {
-            // if we tried to boot from a custom real-time backend and failed,
-            // try reloading and falling back to the siteUrl
-            window.location = window.location.href + '?ws=fallback'
-          }
-          console.error(
-            'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
-          )
-          this.ide.socket = SocketIoShim.stub()
-          this.$scope.$apply(() => {
-            return (this.$scope.state.error =
-              'Could not connect to websocket server :(')
-          })
-          return
+    constructor(ide, $scope) {
+      this.ide = ide
+      this.$scope = $scope
+      this.wsUrl = ide.wsUrl || null // websocket url (if defined)
+      if (typeof io === 'undefined' || io === null) {
+        if (this.wsUrl && !window.location.href.match(/ws=fallback/)) {
+          // if we tried to boot from a custom real-time backend and failed,
+          // try reloading and falling back to the siteUrl
+          window.location = window.location.href + '?ws=fallback'
         }
-
-        setInterval(() => {
-          return this.disconnectIfInactive()
-        }, ONEHOUR)
-
-        // trigger a reconnect immediately if network comes back online
-        window.addEventListener('online', () => {
-          sl_console.log('[online] browser notified online')
-          if (!this.connected) {
-            return this.tryReconnectWithRateLimit({ force: true })
-          }
+        console.error(
+          'Socket.io javascript not loaded. Please check that the real-time service is running and accessible.'
+        )
+        this.ide.socket = SocketIoShim.stub()
+        this.$scope.$apply(() => {
+          return (this.$scope.state.error =
+            'Could not connect to websocket server :(')
         })
+        return
+      }
 
-        this.userIsLeavingPage = false
-        window.addEventListener('beforeunload', () => {
-          this.userIsLeavingPage = true
-        }) // Don't return true or it will show a pop up
-
-        this.connected = false
-        this.userIsInactive = false
-        this.gracefullyReconnecting = false
-
-        this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
-
-        this.$scope.connection = {
-          debug: sl_debugging,
-          reconnecting: false,
-          stillReconnecting: false,
-          // If we need to force everyone to reload the editor
-          forced_disconnect: false,
-          inactive_disconnect: false,
-          jobId: 0
-        }
+      setInterval(() => {
+        return this.disconnectIfInactive()
+      }, ONEHOUR)
 
-        this.$scope.tryReconnectNow = () => {
-          // user manually requested reconnection via "Try now" button
+      // trigger a reconnect immediately if network comes back online
+      window.addEventListener('online', () => {
+        sl_console.log('[online] browser notified online')
+        if (!this.connected) {
           return this.tryReconnectWithRateLimit({ force: true })
         }
+      })
+
+      this.userIsLeavingPage = false
+      window.addEventListener('beforeunload', () => {
+        this.userIsLeavingPage = true
+      }) // Don't return true or it will show a pop up
+
+      this.connected = false
+      this.userIsInactive = false
+      this.gracefullyReconnecting = false
+
+      this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
+
+      this.$scope.connection = {
+        debug: sl_debugging,
+        reconnecting: false,
+        stillReconnecting: false,
+        // If we need to force everyone to reload the editor
+        forced_disconnect: false,
+        inactive_disconnect: false,
+        jobId: 0
+      }
 
-        this.$scope.$on('cursor:editor:update', () => {
-          this.lastUserAction = new Date() // time of last edit
-          if (!this.connected) {
-            // user is editing, try to reconnect
-            return this.tryReconnectWithRateLimit()
-          }
-        })
-
-        document.querySelector('body').addEventListener('click', e => {
-          if (!this.connected && e.target.id !== 'try-reconnect-now-button') {
-            // user is editing, try to reconnect
-            return this.tryReconnectWithRateLimit()
-          }
-        })
+      this.$scope.tryReconnectNow = () => {
+        // user manually requested reconnection via "Try now" button
+        return this.tryReconnectWithRateLimit({ force: true })
+      }
 
-        // initial connection attempt
-        this.updateConnectionManagerState('connecting')
-        let parsedURL
-        try {
-          parsedURL = new URL(this.wsUrl || '/socket.io', window.location)
-        } catch (e) {
-          // hello IE11
-          if (
-            this.wsUrl &&
-            this.wsUrl.indexOf('/') !== 0 &&
-            !window.location.href.match(/ws=fallback/)
-          ) {
-            // do not even try parsing the wsUrl, use the fallback
-            window.location = window.location.href + '?ws=fallback'
-          }
-          parsedURL = {
-            origin: null,
-            pathname: this.wsUrl || '/socket.io'
-          }
+      this.$scope.$on('cursor:editor:update', () => {
+        this.lastUserAction = new Date() // time of last edit
+        if (!this.connected) {
+          // user is editing, try to reconnect
+          return this.tryReconnectWithRateLimit()
         }
-        this.ide.socket = SocketIoShim.connect(
-          parsedURL.origin,
-          {
-            resource: parsedURL.pathname.slice(1),
-            reconnect: false,
-            'connect timeout': 30 * 1000,
-            'force new connection': true
-          }
-        )
-
-        // handle network-level websocket errors (e.g. failed dns lookups)
+      })
 
-        let connectionErrorHandler = err => {
-          this.updateConnectionManagerState('error')
-          sl_console.log('socket.io error', err)
-          if (this.wsUrl && !window.location.href.match(/ws=fallback/)) {
-            // if we tried to load a custom websocket location and failed
-            // try reloading and falling back to the siteUrl
-            window.location = window.location.href + '?ws=fallback'
-          } else {
-            this.connected = false
-            return this.$scope.$apply(() => {
-              return (this.$scope.state.error =
-                "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
-            })
-          }
+      document.querySelector('body').addEventListener('click', e => {
+        if (!this.connected && e.target.id !== 'try-reconnect-now-button') {
+          // user is editing, try to reconnect
+          return this.tryReconnectWithRateLimit()
         }
-        this.ide.socket.on('error', connectionErrorHandler)
-
-        // The "connect" event is the first event we get back. It only
-        // indicates that the websocket is connected, we still need to
-        // pass authentication to join a project.
-
-        this.ide.socket.on('connect', () => {
-          // state should be 'connecting'...
-          // remove connection error handler when connected, avoid unwanted fallbacks
-          this.ide.socket.removeListener('error', connectionErrorHandler)
-          sl_console.log('[socket.io connect] Connected')
-          this.updateConnectionManagerState('authenticating')
-        })
-
-        // The next event we should get is an authentication response
-        // from the server, either "connectionAccepted" or
-        // "connectionRejected".
+      })
+
+      // initial connection attempt
+      this.updateConnectionManagerState('connecting')
+      let parsedURL
+      try {
+        parsedURL = new URL(this.wsUrl || '/socket.io', window.location)
+      } catch (e) {
+        // hello IE11
+        if (
+          this.wsUrl &&
+          this.wsUrl.indexOf('/') !== 0 &&
+          !window.location.href.match(/ws=fallback/)
+        ) {
+          // do not even try parsing the wsUrl, use the fallback
+          window.location = window.location.href + '?ws=fallback'
+        }
+        parsedURL = {
+          origin: null,
+          pathname: this.wsUrl || '/socket.io'
+        }
+      }
+      this.ide.socket = SocketIoShim.connect(
+        parsedURL.origin,
+        {
+          resource: parsedURL.pathname.slice(1),
+          reconnect: false,
+          'connect timeout': 30 * 1000,
+          'force new connection': true
+        }
+      )
 
-        this.ide.socket.on('connectionAccepted', message => {
-          // state should be 'authenticating'...
-          sl_console.log('[socket.io connectionAccepted] allowed to connect')
-          this.connected = true
-          this.gracefullyReconnecting = false
-          this.ide.pushEvent('connected')
-          this.updateConnectionManagerState('joining')
+      // handle network-level websocket errors (e.g. failed dns lookups)
 
-          this.$scope.$apply(() => {
-            if (this.$scope.state.loading) {
-              return (this.$scope.state.load_progress = 70)
-            }
+      let connectionErrorHandler = err => {
+        this.updateConnectionManagerState('error')
+        sl_console.log('socket.io error', err)
+        if (this.wsUrl && !window.location.href.match(/ws=fallback/)) {
+          // if we tried to load a custom websocket location and failed
+          // try reloading and falling back to the siteUrl
+          window.location = window.location.href + '?ws=fallback'
+        } else {
+          this.connected = false
+          return this.$scope.$apply(() => {
+            return (this.$scope.state.error =
+              "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
           })
+        }
+      }
+      this.ide.socket.on('error', connectionErrorHandler)
+
+      // The "connect" event is the first event we get back. It only
+      // indicates that the websocket is connected, we still need to
+      // pass authentication to join a project.
+
+      this.ide.socket.on('connect', () => {
+        // state should be 'connecting'...
+        // remove connection error handler when connected, avoid unwanted fallbacks
+        this.ide.socket.removeListener('error', connectionErrorHandler)
+        sl_console.log('[socket.io connect] Connected')
+        this.updateConnectionManagerState('authenticating')
+      })
+
+      // The next event we should get is an authentication response
+      // from the server, either "connectionAccepted" or
+      // "connectionRejected".
+
+      this.ide.socket.on('connectionAccepted', message => {
+        // state should be 'authenticating'...
+        sl_console.log('[socket.io connectionAccepted] allowed to connect')
+        this.connected = true
+        this.gracefullyReconnecting = false
+        this.ide.pushEvent('connected')
+        this.updateConnectionManagerState('joining')
 
-          // we have passed authentication so we can now join the project
-          let connectionJobId = this.$scope.connection.jobId
-          setTimeout(() => {
-            this.joinProject(connectionJobId)
-          }, 100)
-        })
-
-        this.ide.socket.on('connectionRejected', err => {
-          // state should be 'authenticating'...
-          sl_console.log(
-            '[socket.io connectionRejected] session not valid or other connection error'
-          )
-          // real time sends a 'retry' message if the process was shutting down
-          if (err && err.message === 'retry') {
-            return this.tryReconnectWithRateLimit()
+        this.$scope.$apply(() => {
+          if (this.$scope.state.loading) {
+            return (this.$scope.state.load_progress = 70)
           }
-          // we have failed authentication, usually due to an invalid session cookie
-          return this.reportConnectionError(err)
         })
 
-        // Alternatively the attempt to connect can fail completely, so
-        // we never get into the "connect" state.
+        // we have passed authentication so we can now join the project
+        let connectionJobId = this.$scope.connection.jobId
+        setTimeout(() => {
+          this.joinProject(connectionJobId)
+        }, 100)
+      })
 
-        this.ide.socket.on('connect_failed', () => {
-          this.updateConnectionManagerState('error')
-          this.connected = false
-          return this.$scope.$apply(() => {
-            return (this.$scope.state.error =
-              "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
-          })
+      this.ide.socket.on('connectionRejected', err => {
+        // state should be 'authenticating'...
+        sl_console.log(
+          '[socket.io connectionRejected] session not valid or other connection error'
+        )
+        // real time sends a 'retry' message if the process was shutting down
+        if (err && err.message === 'retry') {
+          return this.tryReconnectWithRateLimit()
+        }
+        // we have failed authentication, usually due to an invalid session cookie
+        return this.reportConnectionError(err)
+      })
+
+      // Alternatively the attempt to connect can fail completely, so
+      // we never get into the "connect" state.
+
+      this.ide.socket.on('connect_failed', () => {
+        this.updateConnectionManagerState('error')
+        this.connected = false
+        return this.$scope.$apply(() => {
+          return (this.$scope.state.error =
+            "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue.")
         })
+      })
 
-        // We can get a "disconnect" event at any point after the
-        // "connect" event.
+      // We can get a "disconnect" event at any point after the
+      // "connect" event.
 
-        this.ide.socket.on('disconnect', () => {
-          sl_console.log('[socket.io disconnect] Disconnected')
-          this.connected = false
-          this.ide.pushEvent('disconnected')
+      this.ide.socket.on('disconnect', () => {
+        sl_console.log('[socket.io disconnect] Disconnected')
+        this.connected = false
+        this.ide.pushEvent('disconnected')
 
-          if (!this.$scope.connection.state.match(/^waiting/)) {
-            if (
-              !this.$scope.connection.forced_disconnect &&
-              !this.userIsInactive
-            ) {
-              this.startAutoReconnectCountdown()
-            } else {
-              this.updateConnectionManagerState('inactive')
-            }
+        if (!this.$scope.connection.state.match(/^waiting/)) {
+          if (
+            !this.$scope.connection.forced_disconnect &&
+            !this.userIsInactive
+          ) {
+            this.startAutoReconnectCountdown()
+          } else {
+            this.updateConnectionManagerState('inactive')
           }
-        })
+        }
+      })
 
-        // Site administrators can send the forceDisconnect event to all users
+      // Site administrators can send the forceDisconnect event to all users
 
-        this.ide.socket.on('forceDisconnect', message => {
-          this.updateConnectionManagerState('inactive')
-          this.$scope.$apply(() => {
-            this.$scope.permissions.write = false
-            return (this.$scope.connection.forced_disconnect = true)
-          })
-          this.ide.socket.disconnect()
-          this.ide.showGenericMessageModal(
-            'Please Refresh',
-            `\
+      this.ide.socket.on('forceDisconnect', message => {
+        this.updateConnectionManagerState('inactive')
+        this.$scope.$apply(() => {
+          this.$scope.permissions.write = false
+          return (this.$scope.connection.forced_disconnect = true)
+        })
+        this.ide.socket.disconnect()
+        this.ide.showGenericMessageModal(
+          'Please Refresh',
+          `\
 We're performing maintenance on Overleaf and you need to refresh the editor.
 Sorry for any inconvenience.
 The editor will refresh in automatically in 10 seconds.\
 `
-          )
-          return setTimeout(() => location.reload(), 10 * 1000)
-        })
+        )
+        return setTimeout(() => location.reload(), 10 * 1000)
+      })
 
-        this.ide.socket.on('reconnectGracefully', () => {
-          sl_console.log('Reconnect gracefully')
-          this.reconnectGracefully()
-        })
-      }
+      this.ide.socket.on('reconnectGracefully', () => {
+        sl_console.log('Reconnect gracefully')
+        this.reconnectGracefully()
+      })
+    }
 
-      updateConnectionManagerState(state) {
-        this.$scope.$apply(() => {
-          this.$scope.connection.jobId += 1
-          let jobId = this.$scope.connection.jobId
+    updateConnectionManagerState(state) {
+      this.$scope.$apply(() => {
+        this.$scope.connection.jobId += 1
+        let jobId = this.$scope.connection.jobId
+        sl_console.log(
+          `[updateConnectionManagerState ${jobId}] from ${
+            this.$scope.connection.state
+          } to ${state}`
+        )
+        this.$scope.connection.state = state
+
+        this.$scope.connection.reconnecting = false
+        this.$scope.connection.stillReconnecting = false
+        this.$scope.connection.inactive_disconnect = false
+        this.$scope.connection.joining = false
+        this.$scope.connection.reconnection_countdown = null
+
+        if (state === 'connecting') {
+          // initial connection
+        } else if (state === 'reconnecting') {
+          // reconnection after a connection has failed
+          this.stopReconnectCountdownTimer()
+          this.$scope.connection.reconnecting = true
+          // if reconnecting takes more than 1s (it doesn't, usually) show the
+          // 'reconnecting...' warning
+          setTimeout(() => {
+            if (
+              this.$scope.connection.reconnecting &&
+              this.$scope.connection.jobId === jobId
+            ) {
+              this.$scope.connection.stillReconnecting = true
+            }
+          }, 1000)
+        } else if (state === 'reconnectFailed') {
+          // reconnect attempt failed
+        } else if (state === 'authenticating') {
+          // socket connection has been established, trying to authenticate
+        } else if (state === 'joining') {
+          // authenticated, joining project
+          this.$scope.connection.joining = true
+        } else if (state === 'ready') {
+          // project has been joined
+        } else if (state === 'waitingCountdown') {
+          // disconnected and waiting to reconnect via the countdown timer
+          this.stopReconnectCountdownTimer()
+        } else if (state === 'waitingGracefully') {
+          // disconnected and waiting to reconnect gracefully
+          this.stopReconnectCountdownTimer()
+        } else if (state === 'inactive') {
+          // disconnected and not trying to reconnect (inactive)
+        } else if (state === 'error') {
+          // something is wrong
+        } else {
           sl_console.log(
-            `[updateConnectionManagerState ${jobId}] from ${
-              this.$scope.connection.state
-            } to ${state}`
+            `[WARN] [updateConnectionManagerState ${jobId}] got unrecognised state ${state}`
           )
-          this.$scope.connection.state = state
-
-          this.$scope.connection.reconnecting = false
-          this.$scope.connection.stillReconnecting = false
-          this.$scope.connection.inactive_disconnect = false
-          this.$scope.connection.joining = false
-          this.$scope.connection.reconnection_countdown = null
-
-          if (state === 'connecting') {
-            // initial connection
-          } else if (state === 'reconnecting') {
-            // reconnection after a connection has failed
-            this.stopReconnectCountdownTimer()
-            this.$scope.connection.reconnecting = true
-            // if reconnecting takes more than 1s (it doesn't, usually) show the
-            // 'reconnecting...' warning
-            setTimeout(() => {
-              if (
-                this.$scope.connection.reconnecting &&
-                this.$scope.connection.jobId === jobId
-              ) {
-                this.$scope.connection.stillReconnecting = true
-              }
-            }, 1000)
-          } else if (state === 'reconnectFailed') {
-            // reconnect attempt failed
-          } else if (state === 'authenticating') {
-            // socket connection has been established, trying to authenticate
-          } else if (state === 'joining') {
-            // authenticated, joining project
-            this.$scope.connection.joining = true
-          } else if (state === 'ready') {
-            // project has been joined
-          } else if (state === 'waitingCountdown') {
-            // disconnected and waiting to reconnect via the countdown timer
-            this.stopReconnectCountdownTimer()
-          } else if (state === 'waitingGracefully') {
-            // disconnected and waiting to reconnect gracefully
-            this.stopReconnectCountdownTimer()
-          } else if (state === 'inactive') {
-            // disconnected and not trying to reconnect (inactive)
-          } else if (state === 'error') {
-            // something is wrong
-          } else {
-            sl_console.log(
-              `[WARN] [updateConnectionManagerState ${jobId}] got unrecognised state ${state}`
-            )
-          }
-        })
-      }
-
-      expectConnectionManagerState(state, jobId) {
-        if (
-          this.$scope.connection.state === state &&
-          (!jobId || jobId === this.$scope.connection.jobId)
-        ) {
-          return true
         }
+      })
+    }
 
-        sl_console.log(
-          `[WARN] [state mismatch] expected state ${state}${
-            jobId ? '/' + jobId : ''
-          } when in ${this.$scope.connection.state}/${
-            this.$scope.connection.jobId
-          }`
-        )
-        return false
+    expectConnectionManagerState(state, jobId) {
+      if (
+        this.$scope.connection.state === state &&
+        (!jobId || jobId === this.$scope.connection.jobId)
+      ) {
+        return true
       }
 
-      // Error reporting, which can reload the page if appropriate
+      sl_console.log(
+        `[WARN] [state mismatch] expected state ${state}${
+          jobId ? '/' + jobId : ''
+        } when in ${this.$scope.connection.state}/${
+          this.$scope.connection.jobId
+        }`
+      )
+      return false
+    }
 
-      reportConnectionError(err) {
-        sl_console.log('[socket.io] reporting connection error')
-        this.updateConnectionManagerState('error')
-        if (
-          (err != null ? err.message : undefined) === 'not authorized' ||
-          (err != null ? err.message : undefined) === 'invalid session'
-        ) {
-          return (window.location = `/login?redir=${encodeURI(
-            window.location.pathname
-          )}`)
-        } else {
-          this.ide.socket.disconnect()
-          return this.ide.showGenericMessageModal(
-            'Something went wrong connecting',
-            `\
+    // Error reporting, which can reload the page if appropriate
+
+    reportConnectionError(err) {
+      sl_console.log('[socket.io] reporting connection error')
+      this.updateConnectionManagerState('error')
+      if (
+        (err != null ? err.message : undefined) === 'not authorized' ||
+        (err != null ? err.message : undefined) === 'invalid session'
+      ) {
+        return (window.location = `/login?redir=${encodeURI(
+          window.location.pathname
+        )}`)
+      } else {
+        this.ide.socket.disconnect()
+        return this.ide.showGenericMessageModal(
+          'Something went wrong connecting',
+          `\
 Something went wrong connecting to your project. Please refresh if this continues to happen.\
 `
-          )
-        }
+        )
       }
+    }
 
-      joinProject(connectionId) {
-        sl_console.log(`[joinProject ${connectionId}] joining...`)
-        // Note: if the "joinProject" message doesn't reach the server
-        // (e.g. if we are in a disconnected state at this point) the
-        // callback will never be executed
-        if (!this.expectConnectionManagerState('joining', connectionId)) {
-          sl_console.log(
-            `[joinProject ${connectionId}] aborting with stale connection`
-          )
-          return
-        }
-        const data = {
-          project_id: this.ide.project_id
-        }
-        if (window.anonymousAccessToken) {
-          data.anonymousAccessToken = window.anonymousAccessToken
-        }
-        this.ide.socket.emit(
-          'joinProject',
-          data,
-          (err, project, permissionsLevel, protocolVersion) => {
-            if (err != null || project == null) {
-              if (err.code === 'TooManyRequests') {
-                sl_console.log(
-                  `[joinProject ${connectionId}] retrying: ${err.message}`
-                )
-                setTimeout(
-                  () => this.joinProject(connectionId),
-                  this.joinProjectRetryInterval
-                )
-                if (
-                  this.joinProjectRetryInterval <
-                  this.JOIN_PROJECT_MAX_RETRY_INTERVAL
-                ) {
-                  this.joinProjectRetryInterval += this.JOIN_PROJECT_RETRY_INTERVAL
-                }
-                return
-              } else {
-                return this.reportConnectionError(err)
+    joinProject(connectionId) {
+      sl_console.log(`[joinProject ${connectionId}] joining...`)
+      // Note: if the "joinProject" message doesn't reach the server
+      // (e.g. if we are in a disconnected state at this point) the
+      // callback will never be executed
+      if (!this.expectConnectionManagerState('joining', connectionId)) {
+        sl_console.log(
+          `[joinProject ${connectionId}] aborting with stale connection`
+        )
+        return
+      }
+      const data = {
+        project_id: this.ide.project_id
+      }
+      if (window.anonymousAccessToken) {
+        data.anonymousAccessToken = window.anonymousAccessToken
+      }
+      this.ide.socket.emit(
+        'joinProject',
+        data,
+        (err, project, permissionsLevel, protocolVersion) => {
+          if (err != null || project == null) {
+            if (err.code === 'TooManyRequests') {
+              sl_console.log(
+                `[joinProject ${connectionId}] retrying: ${err.message}`
+              )
+              setTimeout(
+                () => this.joinProject(connectionId),
+                this.joinProjectRetryInterval
+              )
+              if (
+                this.joinProjectRetryInterval <
+                this.JOIN_PROJECT_MAX_RETRY_INTERVAL
+              ) {
+                this.joinProjectRetryInterval += this.JOIN_PROJECT_RETRY_INTERVAL
               }
+              return
+            } else {
+              return this.reportConnectionError(err)
             }
+          }
 
-            this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
-
-            if (
-              this.$scope.protocolVersion != null &&
-              this.$scope.protocolVersion !== protocolVersion
-            ) {
-              location.reload(true)
-            }
+          this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
 
-            this.$scope.$apply(() => {
-              this.updateConnectionManagerState('ready')
-              this.$scope.protocolVersion = protocolVersion
-              this.$scope.project = project
-              this.$scope.permissionsLevel = permissionsLevel
-              this.$scope.state.load_progress = 100
-              this.$scope.state.loading = false
-              this.$scope.$broadcast('project:joined')
-            })
+          if (
+            this.$scope.protocolVersion != null &&
+            this.$scope.protocolVersion !== protocolVersion
+          ) {
+            location.reload(true)
           }
-        )
-      }
 
-      reconnectImmediately() {
-        this.disconnect()
-        return this.tryReconnect()
-      }
-
-      disconnect() {
-        if (this.ide.socket.socket && !this.ide.socket.socket.connected) {
-          sl_console.log(
-            '[socket.io] skipping disconnect because socket.io has not connected'
-          )
-          return
+          this.$scope.$apply(() => {
+            this.updateConnectionManagerState('ready')
+            this.$scope.protocolVersion = protocolVersion
+            this.$scope.project = project
+            this.$scope.permissionsLevel = permissionsLevel
+            this.$scope.state.load_progress = 100
+            this.$scope.state.loading = false
+            this.$scope.$broadcast('project:joined')
+          })
         }
-        sl_console.log('[socket.io] disconnecting client')
-        return this.ide.socket.disconnect()
-      }
+      )
+    }
 
-      startAutoReconnectCountdown() {
-        this.updateConnectionManagerState('waitingCountdown')
-        let connectionId = this.$scope.connection.jobId
-        let countdown
-        sl_console.log('[ConnectionManager] starting autoreconnect countdown')
-        const twoMinutes = 2 * 60 * 1000
-        if (
-          this.lastUserAction != null &&
-          new Date() - this.lastUserAction > twoMinutes
-        ) {
-          // between 1 minute and 3 minutes
-          countdown = 60 + Math.floor(Math.random() * 120)
-        } else {
-          countdown = 3 + Math.floor(Math.random() * 7)
-        }
+    reconnectImmediately() {
+      this.disconnect()
+      return this.tryReconnect()
+    }
 
-        if (this.userIsLeavingPage) {
-          // user will have pressed refresh or back etc
-          return
-        }
+    disconnect() {
+      if (this.ide.socket.socket && !this.ide.socket.socket.connected) {
+        sl_console.log(
+          '[socket.io] skipping disconnect because socket.io has not connected'
+        )
+        return
+      }
+      sl_console.log('[socket.io] disconnecting client')
+      return this.ide.socket.disconnect()
+    }
 
-        this.$scope.$apply(() => {
-          this.$scope.connection.reconnecting = false
-          this.$scope.connection.stillReconnecting = false
-          this.$scope.connection.joining = false
-          this.$scope.connection.reconnection_countdown = countdown
-        })
+    startAutoReconnectCountdown() {
+      this.updateConnectionManagerState('waitingCountdown')
+      let connectionId = this.$scope.connection.jobId
+      let countdown
+      sl_console.log('[ConnectionManager] starting autoreconnect countdown')
+      const twoMinutes = 2 * 60 * 1000
+      if (
+        this.lastUserAction != null &&
+        new Date() - this.lastUserAction > twoMinutes
+      ) {
+        // between 1 minute and 3 minutes
+        countdown = 60 + Math.floor(Math.random() * 120)
+      } else {
+        countdown = 3 + Math.floor(Math.random() * 7)
+      }
 
-        setTimeout(() => {
-          if (!this.connected && !this.countdownTimeoutId) {
-            this.countdownTimeoutId = setTimeout(
-              () => this.decreaseCountdown(connectionId),
-              1000
-            )
-          }
-        }, 200)
+      if (this.userIsLeavingPage) {
+        // user will have pressed refresh or back etc
+        return
       }
 
-      stopReconnectCountdownTimer() {
-        // clear timeout and set to null so we know there is no countdown running
-        if (this.countdownTimeoutId != null) {
-          sl_console.log(
-            '[ConnectionManager] cancelling existing reconnect timer'
+      this.$scope.$apply(() => {
+        this.$scope.connection.reconnecting = false
+        this.$scope.connection.stillReconnecting = false
+        this.$scope.connection.joining = false
+        this.$scope.connection.reconnection_countdown = countdown
+      })
+
+      setTimeout(() => {
+        if (!this.connected && !this.countdownTimeoutId) {
+          this.countdownTimeoutId = setTimeout(
+            () => this.decreaseCountdown(connectionId),
+            1000
           )
-          clearTimeout(this.countdownTimeoutId)
-          this.countdownTimeoutId = null
         }
-      }
+      }, 200)
+    }
 
-      decreaseCountdown(connectionId) {
+    stopReconnectCountdownTimer() {
+      // clear timeout and set to null so we know there is no countdown running
+      if (this.countdownTimeoutId != null) {
+        sl_console.log(
+          '[ConnectionManager] cancelling existing reconnect timer'
+        )
+        clearTimeout(this.countdownTimeoutId)
         this.countdownTimeoutId = null
-        if (this.$scope.connection.reconnection_countdown == null) {
-          return
-        }
-        if (
-          !this.expectConnectionManagerState('waitingCountdown', connectionId)
-        ) {
-          sl_console.log(
-            `[ConnectionManager] Aborting stale countdown ${connectionId}`
-          )
-          return
-        }
+      }
+    }
 
+    decreaseCountdown(connectionId) {
+      this.countdownTimeoutId = null
+      if (this.$scope.connection.reconnection_countdown == null) {
+        return
+      }
+      if (
+        !this.expectConnectionManagerState('waitingCountdown', connectionId)
+      ) {
         sl_console.log(
-          '[ConnectionManager] decreasing countdown',
-          this.$scope.connection.reconnection_countdown
+          `[ConnectionManager] Aborting stale countdown ${connectionId}`
         )
+        return
+      }
+
+      sl_console.log(
+        '[ConnectionManager] decreasing countdown',
+        this.$scope.connection.reconnection_countdown
+      )
+      this.$scope.$apply(() => {
+        this.$scope.connection.reconnection_countdown--
+      })
+
+      if (this.$scope.connection.reconnection_countdown <= 0) {
+        this.$scope.connection.reconnecting = false
         this.$scope.$apply(() => {
-          this.$scope.connection.reconnection_countdown--
+          this.tryReconnect()
         })
-
-        if (this.$scope.connection.reconnection_countdown <= 0) {
-          this.$scope.connection.reconnecting = false
-          this.$scope.$apply(() => {
-            this.tryReconnect()
-          })
-        } else {
-          this.countdownTimeoutId = setTimeout(
-            () => this.decreaseCountdown(connectionId),
-            1000
-          )
-        }
+      } else {
+        this.countdownTimeoutId = setTimeout(
+          () => this.decreaseCountdown(connectionId),
+          1000
+        )
       }
+    }
 
-      tryReconnect() {
-        sl_console.log('[ConnectionManager] tryReconnect')
-        if (this.connected || this.$scope.connection.reconnecting) {
-          return
-        }
-        this.updateConnectionManagerState('reconnecting')
-        sl_console.log('[ConnectionManager] Starting new connection')
+    tryReconnect() {
+      sl_console.log('[ConnectionManager] tryReconnect')
+      if (this.connected || this.$scope.connection.reconnecting) {
+        return
+      }
+      this.updateConnectionManagerState('reconnecting')
+      sl_console.log('[ConnectionManager] Starting new connection')
 
-        const removeHandler = () => {
-          this.ide.socket.removeListener('error', handleFailure)
-          this.ide.socket.removeListener('connect', handleSuccess)
-        }
-        const handleFailure = () => {
-          sl_console.log('[ConnectionManager] tryReconnect: failed')
-          removeHandler()
-          this.updateConnectionManagerState('reconnectFailed')
-          this.tryReconnectWithRateLimit({ force: true })
-        }
-        const handleSuccess = () => {
-          sl_console.log('[ConnectionManager] tryReconnect: success')
-          removeHandler()
-        }
-        this.ide.socket.on('error', handleFailure)
-        this.ide.socket.on('connect', handleSuccess)
-
-        // use socket.io connect() here to make a single attempt, the
-        // reconnect() method makes multiple attempts
-        this.ide.socket.socket.connect()
-        // record the time of the last attempt to connect
-        this.lastConnectionAttempt = new Date()
+      const removeHandler = () => {
+        this.ide.socket.removeListener('error', handleFailure)
+        this.ide.socket.removeListener('connect', handleSuccess)
+      }
+      const handleFailure = () => {
+        sl_console.log('[ConnectionManager] tryReconnect: failed')
+        removeHandler()
+        this.updateConnectionManagerState('reconnectFailed')
+        this.tryReconnectWithRateLimit({ force: true })
       }
+      const handleSuccess = () => {
+        sl_console.log('[ConnectionManager] tryReconnect: success')
+        removeHandler()
+      }
+      this.ide.socket.on('error', handleFailure)
+      this.ide.socket.on('connect', handleSuccess)
+
+      // use socket.io connect() here to make a single attempt, the
+      // reconnect() method makes multiple attempts
+      this.ide.socket.socket.connect()
+      // record the time of the last attempt to connect
+      this.lastConnectionAttempt = new Date()
+    }
 
-      tryReconnectWithRateLimit(options) {
-        // bail out if the reconnect is already in progress
-        if (this.$scope.connection.reconnecting || this.connected) {
-          return
-        }
-        // bail out if we are going to reconnect soon anyway
-        const reconnectingSoon =
-          this.$scope.connection.reconnection_countdown != null &&
-          this.$scope.connection.reconnection_countdown <= 5
-        const clickedTryNow = options != null ? options.force : undefined // user requested reconnection
-        if (reconnectingSoon && !clickedTryNow) {
-          return
-        }
-        // bail out if we tried reconnecting recently
-        const allowedInterval = clickedTryNow
-          ? this.MIN_RETRY_INTERVAL
-          : this.BACKGROUND_RETRY_INTERVAL
-        if (
-          this.lastConnectionAttempt != null &&
-          new Date() - this.lastConnectionAttempt < allowedInterval
-        ) {
-          if (this.$scope.connection.state !== 'waitingCountdown') {
-            this.startAutoReconnectCountdown()
-          }
-          return
+    tryReconnectWithRateLimit(options) {
+      // bail out if the reconnect is already in progress
+      if (this.$scope.connection.reconnecting || this.connected) {
+        return
+      }
+      // bail out if we are going to reconnect soon anyway
+      const reconnectingSoon =
+        this.$scope.connection.reconnection_countdown != null &&
+        this.$scope.connection.reconnection_countdown <= 5
+      const clickedTryNow = options != null ? options.force : undefined // user requested reconnection
+      if (reconnectingSoon && !clickedTryNow) {
+        return
+      }
+      // bail out if we tried reconnecting recently
+      const allowedInterval = clickedTryNow
+        ? this.MIN_RETRY_INTERVAL
+        : this.BACKGROUND_RETRY_INTERVAL
+      if (
+        this.lastConnectionAttempt != null &&
+        new Date() - this.lastConnectionAttempt < allowedInterval
+      ) {
+        if (this.$scope.connection.state !== 'waitingCountdown') {
+          this.startAutoReconnectCountdown()
         }
-        this.tryReconnect()
+        return
       }
+      this.tryReconnect()
+    }
 
-      disconnectIfInactive() {
-        this.userIsInactive =
-          new Date() - this.lastUserAction > this.disconnectAfterMs
-        if (this.userIsInactive && this.connected) {
-          this.disconnect()
-          return this.$scope.$apply(() => {
-            return (this.$scope.connection.inactive_disconnect = true)
-          }) // 5 minutes
-        }
+    disconnectIfInactive() {
+      this.userIsInactive =
+        new Date() - this.lastUserAction > this.disconnectAfterMs
+      if (this.userIsInactive && this.connected) {
+        this.disconnect()
+        return this.$scope.$apply(() => {
+          return (this.$scope.connection.inactive_disconnect = true)
+        }) // 5 minutes
       }
-      reconnectGracefully(force) {
-        if (this.reconnectGracefullyStarted == null) {
-          this.reconnectGracefullyStarted = new Date()
-        } else {
-          if (!force) {
-            sl_console.log(
-              '[reconnectGracefully] reconnection is already in process, so skipping'
-            )
-            return
-          }
-        }
-        const userIsInactive =
-          new Date() - this.lastUserAction >
-          this.RECONNECT_GRACEFULLY_RETRY_INTERVAL
-        const maxIntervalReached =
-          new Date() - this.reconnectGracefullyStarted >
-          this.MAX_RECONNECT_GRACEFULLY_INTERVAL
-        if (userIsInactive || maxIntervalReached) {
-          sl_console.log(
-            "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
-          )
-          this._reconnectGracefullyNow()
-        } else {
+    }
+    reconnectGracefully(force) {
+      if (this.reconnectGracefullyStarted == null) {
+        this.reconnectGracefullyStarted = new Date()
+      } else {
+        if (!force) {
           sl_console.log(
-            '[reconnectGracefully] User is working, will try again in 5 seconds'
+            '[reconnectGracefully] reconnection is already in process, so skipping'
           )
-          this.updateConnectionManagerState('waitingGracefully')
-          setTimeout(() => {
-            this.reconnectGracefully(true)
-          }, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL)
+          return
         }
       }
-
-      _reconnectGracefullyNow() {
-        this.gracefullyReconnecting = true
-        this.reconnectGracefullyStarted = null
-        // Clear cookie so we don't go to the same backend server
-        $.cookie('SERVERID', '', { expires: -1, path: '/' })
-        return this.reconnectImmediately()
+      const userIsInactive =
+        new Date() - this.lastUserAction >
+        this.RECONNECT_GRACEFULLY_RETRY_INTERVAL
+      const maxIntervalReached =
+        new Date() - this.reconnectGracefullyStarted >
+        this.MAX_RECONNECT_GRACEFULLY_INTERVAL
+      if (userIsInactive || maxIntervalReached) {
+        sl_console.log(
+          "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
+        )
+        this._reconnectGracefullyNow()
+      } else {
+        sl_console.log(
+          '[reconnectGracefully] User is working, will try again in 5 seconds'
+        )
+        this.updateConnectionManagerState('waitingGracefully')
+        setTimeout(() => {
+          this.reconnectGracefully(true)
+        }, this.RECONNECT_GRACEFULLY_RETRY_INTERVAL)
       }
     }
-    ConnectionManager.initClass()
-    return ConnectionManager
-  })())
-})
+
+    _reconnectGracefullyNow() {
+      this.gracefullyReconnecting = true
+      this.reconnectGracefullyStarted = null
+      // Clear cookie so we don't go to the same backend server
+      $.cookie('SERVERID', '', { expires: -1, path: '/' })
+      return this.reconnectImmediately()
+    }
+  }
+  ConnectionManager.initClass()
+  return ConnectionManager
+})())

+ 195 - 197
services/web/frontend/js/ide/connection/SocketIoShim.js

@@ -1,224 +1,222 @@
 /* global io */
 
-define([], function() {
-  class SocketShimBase {
-    static connect(url, options) {
-      return new SocketShimBase()
-    }
-    constructor(socket) {
-      this._socket = socket
-    }
+class SocketShimBase {
+  static connect(url, options) {
+    return new SocketShimBase()
   }
-  const transparentMethods = [
-    'connect',
-    'disconnect',
-    'emit',
-    'on',
-    'removeListener'
-  ]
-  for (let method of transparentMethods) {
-    SocketShimBase.prototype[method] = function() {
-      this._socket[method].apply(this._socket, arguments)
-    }
+  constructor(socket) {
+    this._socket = socket
   }
+}
+const transparentMethods = [
+  'connect',
+  'disconnect',
+  'emit',
+  'on',
+  'removeListener'
+]
+for (let method of transparentMethods) {
+  SocketShimBase.prototype[method] = function() {
+    this._socket[method].apply(this._socket, arguments)
+  }
+}
 
-  class SocketShimNoop extends SocketShimBase {
-    static connect() {
-      return new SocketShimNoop()
-    }
-    constructor(socket) {
-      super(socket)
-      this.socket = {
-        get connected() {
-          return false
-        },
-        get sessionid() {
-          return undefined
-        },
-        get transport() {
-          return {}
-        },
-
-        connect() {},
-        disconnect(reason) {}
-      }
+class SocketShimNoop extends SocketShimBase {
+  static connect() {
+    return new SocketShimNoop()
+  }
+  constructor(socket) {
+    super(socket)
+    this.socket = {
+      get connected() {
+        return false
+      },
+      get sessionid() {
+        return undefined
+      },
+      get transport() {
+        return {}
+      },
+
+      connect() {},
+      disconnect(reason) {}
     }
-    connect() {}
-    disconnect(reason) {}
-    emit() {}
-    on() {}
-    removeListener() {}
-  }
-
-  class SocketShimV0 extends SocketShimBase {
-    static connect(url, options) {
-      return new SocketShimV0(
-        io.connect(
-          url,
-          options
-        )
+  }
+  connect() {}
+  disconnect(reason) {}
+  emit() {}
+  on() {}
+  removeListener() {}
+}
+
+class SocketShimV0 extends SocketShimBase {
+  static connect(url, options) {
+    return new SocketShimV0(
+      io.connect(
+        url,
+        options
       )
-    }
-    constructor(socket) {
-      super(socket)
-      this.socket = this._socket.socket
-    }
+    )
   }
-
-  class SocketShimV2 extends SocketShimBase {
-    static connect(url, options) {
-      options.forceNew = options['force new connection']
-      // .resource has no leading slash, path wants to see one.
-      options.path = '/' + options.resource
-      options.reconnection = options.reconnect
-      options.timeout = options['connect timeout']
-      return new SocketShimV2(url, options)
-    }
-    static get EVENT_MAP() {
-      // Use the v2 event names transparently to the frontend.
-      const connectionFailureEvents = [
-        'connect_error',
-        'connect_timeout',
-        'error'
-      ]
-      return new Map([
-        ['connect_failed', connectionFailureEvents],
-        ['error', connectionFailureEvents]
-      ])
-    }
-    _on(event, handler) {
-      // Keep track of our event listeners.
-      // We move them to a new socket in ._replaceSocketWithNewInstance()
-      if (!this._events.has(event)) {
-        this._events.set(event, [handler])
-      } else {
-        this._events.get(event).push(handler)
-      }
-      this._socket.on(event, handler)
-    }
-    on(event, handler) {
-      if (SocketShimV2.EVENT_MAP.has(event)) {
-        for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) {
-          this._on(v2Event, handler)
-        }
-      } else {
-        this._on(event, handler)
+  constructor(socket) {
+    super(socket)
+    this.socket = this._socket.socket
+  }
+}
+
+class SocketShimV2 extends SocketShimBase {
+  static connect(url, options) {
+    options.forceNew = options['force new connection']
+    // .resource has no leading slash, path wants to see one.
+    options.path = '/' + options.resource
+    options.reconnection = options.reconnect
+    options.timeout = options['connect timeout']
+    return new SocketShimV2(url, options)
+  }
+  static get EVENT_MAP() {
+    // Use the v2 event names transparently to the frontend.
+    const connectionFailureEvents = [
+      'connect_error',
+      'connect_timeout',
+      'error'
+    ]
+    return new Map([
+      ['connect_failed', connectionFailureEvents],
+      ['error', connectionFailureEvents]
+    ])
+  }
+  _on(event, handler) {
+    // Keep track of our event listeners.
+    // We move them to a new socket in ._replaceSocketWithNewInstance()
+    if (!this._events.has(event)) {
+      this._events.set(event, [handler])
+    } else {
+      this._events.get(event).push(handler)
+    }
+    this._socket.on(event, handler)
+  }
+  on(event, handler) {
+    if (SocketShimV2.EVENT_MAP.has(event)) {
+      for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) {
+        this._on(v2Event, handler)
       }
+    } else {
+      this._on(event, handler)
     }
-    _removeListener(event, handler) {
-      // Keep track of our event listeners.
-      // We move them to a new socket in ._replaceSocketWithNewInstance()
-      if (this._events.has(event)) {
-        const listeners = this._events.get(event)
-        const pos = listeners.indexOf(handler)
-        if (pos !== -1) {
-          listeners.splice(pos, 1)
-        }
+  }
+  _removeListener(event, handler) {
+    // Keep track of our event listeners.
+    // We move them to a new socket in ._replaceSocketWithNewInstance()
+    if (this._events.has(event)) {
+      const listeners = this._events.get(event)
+      const pos = listeners.indexOf(handler)
+      if (pos !== -1) {
+        listeners.splice(pos, 1)
       }
-      this._socket.removeListener(event, handler)
     }
-    removeListener(event, handler) {
-      if (SocketShimV2.EVENT_MAP.has(event)) {
-        for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) {
-          this._removeListener(v2Event, handler)
-        }
-      } else {
-        this._removeListener(event, handler)
+    this._socket.removeListener(event, handler)
+  }
+  removeListener(event, handler) {
+    if (SocketShimV2.EVENT_MAP.has(event)) {
+      for (const v2Event of SocketShimV2.EVENT_MAP.get(event)) {
+        this._removeListener(v2Event, handler)
       }
+    } else {
+      this._removeListener(event, handler)
     }
+  }
 
-    static createNewSocket(url, options) {
-      // open a brand new connection for the default namespace '/'
-      // The old socket can still leak 'disconnect' events from the teardown
-      //  of the old transport. The leaking 'disconnect' events interfere with
-      //  the _new_ connection and cancel the new connect attempt.
-      // Also skip the caching in these locations:
-      // - `io.connect()` caches `io.Manager`s in `io.managers`
-      // - `io.Manager().socket()` caches `io.Socket`s in its `this.nsps`
-      return io.Manager(url, options).socket('/', options)
-    }
-
-    _replaceSocketWithNewInstance() {
-      const oldSocket = this._socket
-      const newSocket = SocketShimV2.createNewSocket(this._url, this._options)
+  static createNewSocket(url, options) {
+    // open a brand new connection for the default namespace '/'
+    // The old socket can still leak 'disconnect' events from the teardown
+    //  of the old transport. The leaking 'disconnect' events interfere with
+    //  the _new_ connection and cancel the new connect attempt.
+    // Also skip the caching in these locations:
+    // - `io.connect()` caches `io.Manager`s in `io.managers`
+    // - `io.Manager().socket()` caches `io.Socket`s in its `this.nsps`
+    return io.Manager(url, options).socket('/', options)
+  }
 
-      // move our existing event handlers to the new socket
-      this._events.forEach((listeners, event) => {
-        for (const listener of listeners) {
-          oldSocket.removeListener(event, listener)
-          newSocket.on(event, listener)
-        }
-      })
+  _replaceSocketWithNewInstance() {
+    const oldSocket = this._socket
+    const newSocket = SocketShimV2.createNewSocket(this._url, this._options)
 
-      if (oldSocket.connected) {
-        // We overwrite the reference to oldSocket soon.
-        // Make sure we are disconnected.
-        oldSocket.disconnect()
+    // move our existing event handlers to the new socket
+    this._events.forEach((listeners, event) => {
+      for (const listener of listeners) {
+        oldSocket.removeListener(event, listener)
+        newSocket.on(event, listener)
       }
-      this._socket = newSocket
-    }
+    })
 
-    connect() {
-      // have the same logic behind socket.connect and socket.socket.connect
-      this._replaceSocketWithNewInstance()
+    if (oldSocket.connected) {
+      // We overwrite the reference to oldSocket soon.
+      // Make sure we are disconnected.
+      oldSocket.disconnect()
     }
+    this._socket = newSocket
+  }
 
-    constructor(url, options) {
-      super(SocketShimV2.createNewSocket(url, options))
-      this._url = url
-      this._options = options
-      this._events = new Map()
+  connect() {
+    // have the same logic behind socket.connect and socket.socket.connect
+    this._replaceSocketWithNewInstance()
+  }
 
-      const self = this
-      function _getEngine() {
-        return (self._socket.io && self._socket.io.engine) || {}
-      }
+  constructor(url, options) {
+    super(SocketShimV2.createNewSocket(url, options))
+    this._url = url
+    this._options = options
+    this._events = new Map()
+
+    const self = this
+    function _getEngine() {
+      return (self._socket.io && self._socket.io.engine) || {}
+    }
 
-      this.socket = {
-        get connected() {
-          return self._socket.connected
-        },
-        get sessionid() {
-          if (self._socket.id) {
-            return self._socket.id
-          }
-          // socket.id is discarded upon disconnect
-          // the id is still available in the internal state
-          return _getEngine().id
-        },
-        get transport() {
-          return _getEngine().transport
-        },
-
-        connect() {
-          self._replaceSocketWithNewInstance()
-        },
-        disconnect(reason) {
-          return self._socket.disconnect(reason)
+    this.socket = {
+      get connected() {
+        return self._socket.connected
+      },
+      get sessionid() {
+        if (self._socket.id) {
+          return self._socket.id
         }
+        // socket.id is discarded upon disconnect
+        // the id is still available in the internal state
+        return _getEngine().id
+      },
+      get transport() {
+        return _getEngine().transport
+      },
+
+      connect() {
+        self._replaceSocketWithNewInstance()
+      },
+      disconnect(reason) {
+        return self._socket.disconnect(reason)
       }
     }
   }
-
-  let current
-  if (typeof io === 'undefined' || !io) {
-    sl_console.log('[socket.io] Shim: socket.io is not loaded, returning noop')
-    current = SocketShimNoop
-  } else if (typeof io.version === 'string' && io.version.slice(0, 1) === '0') {
-    sl_console.log('[socket.io] Shim: detected v0')
-    current = SocketShimV0
-  } else {
-    // socket.io v2 does not have a global io.version attribute.
-    sl_console.log('[socket.io] Shim: detected v2')
-    current = SocketShimV2
-  }
-
-  return {
-    SocketShimNoop,
-    SocketShimV0,
-    SocketShimV2,
-    current,
-    connect: current.connect,
-    stub: () => new SocketShimNoop()
-  }
-})
+}
+
+let current
+if (typeof io === 'undefined' || !io) {
+  sl_console.log('[socket.io] Shim: socket.io is not loaded, returning noop')
+  current = SocketShimNoop
+} else if (typeof io.version === 'string' && io.version.slice(0, 1) === '0') {
+  sl_console.log('[socket.io] Shim: detected v0')
+  current = SocketShimV0
+} else {
+  // socket.io v2 does not have a global io.version attribute.
+  sl_console.log('[socket.io] Shim: detected v2')
+  current = SocketShimV2
+}
+
+export default {
+  SocketShimNoop,
+  SocketShimV0,
+  SocketShimV2,
+  current,
+  connect: current.connect,
+  stub: () => new SocketShimNoop()
+}

+ 40 - 48
services/web/frontend/js/ide/directives/SafePath.js

@@ -16,10 +16,9 @@
 //   app/coffee/Features/Project/SafePath.coffee
 //   public/coffee/ide/directives/SafePath.coffee
 
-const load = function() {
-  let SafePath
-  const BADCHAR_RX = new RegExp(
-    `\
+let SafePath
+const BADCHAR_RX = new RegExp(
+  `\
 [\
 \\/\
 \\\\\
@@ -30,27 +29,27 @@ const load = function() {
 \\uD800-\\uDFFF\
 ]\
 `,
-    'g'
-  )
+  'g'
+)
 
-  const BADFILE_RX = new RegExp(
-    `\
+const BADFILE_RX = new RegExp(
+  `\
 (^\\.$)\
 |(^\\.\\.$)\
 |(^\\s+)\
 |(\\s+$)\
 `,
-    'g'
-  )
+  'g'
+)
 
-  // Put a block on filenames which match javascript property names, as they
-  // can cause exceptions where the code puts filenames into a hash. This is a
-  // temporary workaround until the code in other places is made safe against
-  // property names.
-  //
-  // The list of property names is taken from
-  //   ['prototype'].concat(Object.getOwnPropertyNames(Object.prototype))
-  const BLOCKEDFILE_RX = new RegExp(`\
+// Put a block on filenames which match javascript property names, as they
+// can cause exceptions where the code puts filenames into a hash. This is a
+// temporary workaround until the code in other places is made safe against
+// property names.
+//
+// The list of property names is taken from
+//   ['prototype'].concat(Object.getOwnPropertyNames(Object.prototype))
+const BLOCKEDFILE_RX = new RegExp(`\
 ^(\
 prototype\
 |constructor\
@@ -68,36 +67,29 @@ prototype\
 )$\
 `)
 
-  const MAX_PATH = 1024 // Maximum path length, in characters. This is fairly arbitrary.
+const MAX_PATH = 1024 // Maximum path length, in characters. This is fairly arbitrary.
 
-  return (SafePath = {
-    clean(filename) {
-      filename = filename.replace(BADCHAR_RX, '_')
-      // for BADFILE_RX replace any matches with an equal number of underscores
-      filename = filename.replace(BADFILE_RX, match =>
-        new Array(match.length + 1).join('_')
-      )
-      // replace blocked filenames 'prototype' with '@prototype'
-      filename = filename.replace(BLOCKEDFILE_RX, '@$1')
-      return filename
-    },
+export default (SafePath = {
+  clean(filename) {
+    filename = filename.replace(BADCHAR_RX, '_')
+    // for BADFILE_RX replace any matches with an equal number of underscores
+    filename = filename.replace(BADFILE_RX, match =>
+      new Array(match.length + 1).join('_')
+    )
+    // replace blocked filenames 'prototype' with '@prototype'
+    filename = filename.replace(BLOCKEDFILE_RX, '@$1')
+    return filename
+  },
 
-    isCleanFilename(filename) {
-      return (
-        SafePath.isAllowedLength(filename) &&
-        !filename.match(BADCHAR_RX) &&
-        !filename.match(BADFILE_RX)
-      )
-    },
+  isCleanFilename(filename) {
+    return (
+      SafePath.isAllowedLength(filename) &&
+      !filename.match(BADCHAR_RX) &&
+      !filename.match(BADFILE_RX)
+    )
+  },
 
-    isAllowedLength(pathname) {
-      return pathname.length > 0 && pathname.length <= MAX_PATH
-    }
-  })
-}
-
-if (typeof define !== 'undefined' && define !== null) {
-  define([], load)
-} else {
-  module.exports = load()
-}
+  isAllowedLength(pathname) {
+    return pathname.length > 0 && pathname.length <= MAX_PATH
+  }
+})

+ 225 - 227
services/web/frontend/js/ide/directives/layout.js

@@ -12,199 +12,197 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  '../../base',
-  'libs/jquery-layout',
-  'libs/jquery.ui.touch-punch'
-], App =>
-  App.directive('layout', ($parse, $compile, ide) => ({
-    compile() {
-      return {
-        pre(scope, element, attrs) {
-          let customTogglerEl, spacingClosed, spacingOpen, state
-          const name = attrs.layout
+import App from '../../base'
+import 'libs/jquery-layout'
+import 'libs/jquery.ui.touch-punch'
 
-          const { customTogglerPane } = attrs
-          const { customTogglerMsgWhenOpen } = attrs
-          const { customTogglerMsgWhenClosed } = attrs
-          const hasCustomToggler =
-            customTogglerPane != null &&
-            customTogglerMsgWhenOpen != null &&
-            customTogglerMsgWhenClosed != null
+export default App.directive('layout', ($parse, $compile, ide) => ({
+  compile() {
+    return {
+      pre(scope, element, attrs) {
+        let customTogglerEl, spacingClosed, spacingOpen, state
+        const name = attrs.layout
 
-          if (attrs.spacingOpen != null) {
-            spacingOpen = parseInt(attrs.spacingOpen, 10)
-          } else {
-            spacingOpen = window.uiConfig.defaultResizerSizeOpen
-          }
+        const { customTogglerPane } = attrs
+        const { customTogglerMsgWhenOpen } = attrs
+        const { customTogglerMsgWhenClosed } = attrs
+        const hasCustomToggler =
+          customTogglerPane != null &&
+          customTogglerMsgWhenOpen != null &&
+          customTogglerMsgWhenClosed != null
 
-          if (attrs.spacingClosed != null) {
-            spacingClosed = parseInt(attrs.spacingClosed, 10)
-          } else {
-            spacingClosed = window.uiConfig.defaultResizerSizeClosed
-          }
+        if (attrs.spacingOpen != null) {
+          spacingOpen = parseInt(attrs.spacingOpen, 10)
+        } else {
+          spacingOpen = window.uiConfig.defaultResizerSizeOpen
+        }
 
-          const options = {
-            spacing_open: spacingOpen,
-            spacing_closed: spacingClosed,
-            slidable: false,
-            enableCursorHotkey: false,
-            onopen: pane => {
-              return onPaneOpen(pane)
-            },
-            onclose: pane => {
-              return onPaneClose(pane)
-            },
-            onresize: () => {
-              return onInternalResize()
-            },
-            maskIframesOnResize: scope.$eval(
-              attrs.maskIframesOnResize || 'false'
-            ),
-            east: {
-              size: scope.$eval(attrs.initialSizeEast),
-              initClosed: scope.$eval(attrs.initClosedEast)
-            },
-            west: {
-              size: scope.$eval(attrs.initialSizeEast),
-              initClosed: scope.$eval(attrs.initClosedWest)
-            }
+        if (attrs.spacingClosed != null) {
+          spacingClosed = parseInt(attrs.spacingClosed, 10)
+        } else {
+          spacingClosed = window.uiConfig.defaultResizerSizeClosed
+        }
+
+        const options = {
+          spacing_open: spacingOpen,
+          spacing_closed: spacingClosed,
+          slidable: false,
+          enableCursorHotkey: false,
+          onopen: pane => {
+            return onPaneOpen(pane)
+          },
+          onclose: pane => {
+            return onPaneClose(pane)
+          },
+          onresize: () => {
+            return onInternalResize()
+          },
+          maskIframesOnResize: scope.$eval(
+            attrs.maskIframesOnResize || 'false'
+          ),
+          east: {
+            size: scope.$eval(attrs.initialSizeEast),
+            initClosed: scope.$eval(attrs.initClosedEast)
+          },
+          west: {
+            size: scope.$eval(attrs.initialSizeEast),
+            initClosed: scope.$eval(attrs.initClosedWest)
           }
+        }
 
-          // Restore previously recorded state
-          if ((state = ide.localStorage(`layout.${name}`)) != null) {
-            if (state.east != null) {
-              if (
-                attrs.minimumRestoreSizeEast == null ||
-                (state.east.size >= attrs.minimumRestoreSizeEast &&
-                  !state.east.initClosed)
-              ) {
-                options.east = state.east
-              }
+        // Restore previously recorded state
+        if ((state = ide.localStorage(`layout.${name}`)) != null) {
+          if (state.east != null) {
+            if (
+              attrs.minimumRestoreSizeEast == null ||
+              (state.east.size >= attrs.minimumRestoreSizeEast &&
+                !state.east.initClosed)
+            ) {
+              options.east = state.east
             }
-            if (state.west != null) {
-              if (
-                attrs.minimumRestoreSizeWest == null ||
-                (state.west.size >= attrs.minimumRestoreSizeWest &&
-                  !state.west.initClosed)
-              ) {
-                options.west = state.west
-              }
+          }
+          if (state.west != null) {
+            if (
+              attrs.minimumRestoreSizeWest == null ||
+              (state.west.size >= attrs.minimumRestoreSizeWest &&
+                !state.west.initClosed)
+            ) {
+              options.west = state.west
             }
           }
+        }
 
-          if (window.uiConfig.eastResizerCursor != null) {
-            options.east.resizerCursor = window.uiConfig.eastResizerCursor
-          }
+        if (window.uiConfig.eastResizerCursor != null) {
+          options.east.resizerCursor = window.uiConfig.eastResizerCursor
+        }
 
-          if (window.uiConfig.westResizerCursor != null) {
-            options.west.resizerCursor = window.uiConfig.westResizerCursor
-          }
+        if (window.uiConfig.westResizerCursor != null) {
+          options.west.resizerCursor = window.uiConfig.westResizerCursor
+        }
 
-          const repositionControls = function() {
-            state = element.layout().readState()
-            if (state.east != null) {
-              const controls = element.find('> .ui-layout-resizer-controls')
-              if (state.east.initClosed) {
-                return controls.hide()
-              } else {
-                controls.show()
-                return controls.css({
-                  right: state.east.size
-                })
-              }
+        const repositionControls = function() {
+          state = element.layout().readState()
+          if (state.east != null) {
+            const controls = element.find('> .ui-layout-resizer-controls')
+            if (state.east.initClosed) {
+              return controls.hide()
+            } else {
+              controls.show()
+              return controls.css({
+                right: state.east.size
+              })
             }
           }
+        }
 
-          const repositionCustomToggler = function() {
-            if (customTogglerEl == null) {
-              return
-            }
-            state = element.layout().readState()
-            const positionAnchor =
-              customTogglerPane === 'east' ? 'right' : 'left'
-            const paneState = state[customTogglerPane]
-            if (paneState != null) {
-              return customTogglerEl.css(
-                positionAnchor,
-                paneState.initClosed ? 0 : paneState.size
-              )
-            }
+        const repositionCustomToggler = function() {
+          if (customTogglerEl == null) {
+            return
+          }
+          state = element.layout().readState()
+          const positionAnchor = customTogglerPane === 'east' ? 'right' : 'left'
+          const paneState = state[customTogglerPane]
+          if (paneState != null) {
+            return customTogglerEl.css(
+              positionAnchor,
+              paneState.initClosed ? 0 : paneState.size
+            )
           }
+        }
 
-          const resetOpenStates = function() {
-            state = element.layout().readState()
-            if (attrs.openEast != null && state.east != null) {
-              const openEast = $parse(attrs.openEast)
-              return openEast.assign(scope, !state.east.initClosed)
-            }
+        const resetOpenStates = function() {
+          state = element.layout().readState()
+          if (attrs.openEast != null && state.east != null) {
+            const openEast = $parse(attrs.openEast)
+            return openEast.assign(scope, !state.east.initClosed)
           }
+        }
 
-          // Someone moved the resizer
-          var onInternalResize = function() {
-            state = element.layout().readState()
-            scope.$broadcast(`layout:${name}:resize`, state)
-            repositionControls()
-            if (hasCustomToggler) {
-              repositionCustomToggler()
-            }
-            return resetOpenStates()
+        // Someone moved the resizer
+        var onInternalResize = function() {
+          state = element.layout().readState()
+          scope.$broadcast(`layout:${name}:resize`, state)
+          repositionControls()
+          if (hasCustomToggler) {
+            repositionCustomToggler()
           }
+          return resetOpenStates()
+        }
 
-          let oldWidth = element.width()
-          // Something resized our parent element
-          const onExternalResize = function() {
-            if (
-              attrs.resizeProportionally != null &&
-              scope.$eval(attrs.resizeProportionally)
-            ) {
-              const eastState = element.layout().readState().east
-              if (eastState != null) {
-                const newInternalWidth =
-                  (eastState.size / oldWidth) * element.width()
-                oldWidth = element.width()
-                element.layout().sizePane('east', newInternalWidth)
-                return
-              }
+        let oldWidth = element.width()
+        // Something resized our parent element
+        const onExternalResize = function() {
+          if (
+            attrs.resizeProportionally != null &&
+            scope.$eval(attrs.resizeProportionally)
+          ) {
+            const eastState = element.layout().readState().east
+            if (eastState != null) {
+              const newInternalWidth =
+                (eastState.size / oldWidth) * element.width()
+              oldWidth = element.width()
+              element.layout().sizePane('east', newInternalWidth)
+              return
             }
-
-            return element.layout().resizeAll()
           }
 
-          element.layout(options)
-          element.layout().resizeAll()
+          return element.layout().resizeAll()
+        }
 
-          if (attrs.resizeOn != null) {
-            for (let event of Array.from(attrs.resizeOn.split(','))) {
-              scope.$on(event, () => onExternalResize())
-            }
+        element.layout(options)
+        element.layout().resizeAll()
+
+        if (attrs.resizeOn != null) {
+          for (let event of Array.from(attrs.resizeOn.split(','))) {
+            scope.$on(event, () => onExternalResize())
           }
+        }
 
-          if (hasCustomToggler) {
-            state = element.layout().readState()
-            const customTogglerScope = scope.$new()
+        if (hasCustomToggler) {
+          state = element.layout().readState()
+          const customTogglerScope = scope.$new()
 
-            customTogglerScope.isOpen = true
-            customTogglerScope.isVisible = true
+          customTogglerScope.isOpen = true
+          customTogglerScope.isVisible = true
 
-            if (
-              (state[customTogglerPane] != null
-                ? state[customTogglerPane].initClosed
-                : undefined) === true
-            ) {
-              customTogglerScope.isOpen = false
-            }
+          if (
+            (state[customTogglerPane] != null
+              ? state[customTogglerPane].initClosed
+              : undefined) === true
+          ) {
+            customTogglerScope.isOpen = false
+          }
 
-            customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
-            customTogglerScope.tooltipMsgWhenClosed = customTogglerMsgWhenClosed
+          customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
+          customTogglerScope.tooltipMsgWhenClosed = customTogglerMsgWhenClosed
 
-            customTogglerScope.tooltipPlacement =
-              customTogglerPane === 'east' ? 'left' : 'right'
-            customTogglerScope.handleClick = function() {
-              element.layout().toggle(customTogglerPane)
-              return repositionCustomToggler()
-            }
-            customTogglerEl = $compile(`\
+          customTogglerScope.tooltipPlacement =
+            customTogglerPane === 'east' ? 'left' : 'right'
+          customTogglerScope.handleClick = function() {
+            element.layout().toggle(customTogglerPane)
+            return repositionCustomToggler()
+          }
+          customTogglerEl = $compile(`\
 <a href \
 ng-show=\"isVisible\" \
 class=\"custom-toggler ${`custom-toggler-${customTogglerPane}`}\" \
@@ -213,81 +211,81 @@ tooltip=\"{{ isOpen ? tooltipMsgWhenOpen : tooltipMsgWhenClosed }}\" \
 tooltip-placement=\"{{ tooltipPlacement }}\" \
 ng-click=\"handleClick()\">\
 `)(customTogglerScope)
-            element.append(customTogglerEl)
-          }
-
-          var onPaneOpen = function(pane) {
-            if (!hasCustomToggler && pane !== customTogglerPane) {
-              return
-            }
-            return customTogglerEl
-              .scope()
-              .$applyAsync(() => (customTogglerEl.scope().isOpen = true))
-          }
-
-          var onPaneClose = function(pane) {
-            if (!hasCustomToggler && pane !== customTogglerPane) {
-              return
-            }
-            return customTogglerEl
-              .scope()
-              .$applyAsync(() => (customTogglerEl.scope().isOpen = false))
-          }
-
-          // Save state when exiting
-          $(window).unload(() =>
-            ide.localStorage(`layout.${name}`, element.layout().readState())
-          )
+          element.append(customTogglerEl)
+        }
 
-          if (attrs.openEast != null) {
-            scope.$watch(attrs.openEast, function(value, oldValue) {
-              if (value != null && value !== oldValue) {
-                if (value) {
-                  element.layout().open('east')
-                } else {
-                  element.layout().close('east')
-                }
-              }
-              return setTimeout(() => scope.$digest(), 0)
-            })
+        var onPaneOpen = function(pane) {
+          if (!hasCustomToggler && pane !== customTogglerPane) {
+            return
           }
+          return customTogglerEl
+            .scope()
+            .$applyAsync(() => (customTogglerEl.scope().isOpen = true))
+        }
 
-          if (attrs.allowOverflowOn != null) {
-            const layoutObj = element.layout()
-            const overflowPane = scope.$eval(attrs.allowOverflowOn)
-            const overflowPaneEl = layoutObj.panes[overflowPane]
-            // Set the panel as overflowing (gives it higher z-index and sets overflow rules)
-            layoutObj.allowOverflow(overflowPane)
-            // Read the given z-index value and increment it, so that it's higher than synctex controls.
-            const overflowPaneZVal = overflowPaneEl.zIndex()
-            overflowPaneEl.css('z-index', overflowPaneZVal + 1)
+        var onPaneClose = function(pane) {
+          if (!hasCustomToggler && pane !== customTogglerPane) {
+            return
           }
+          return customTogglerEl
+            .scope()
+            .$applyAsync(() => (customTogglerEl.scope().isOpen = false))
+        }
 
-          resetOpenStates()
-          onInternalResize()
+        // Save state when exiting
+        $(window).unload(() =>
+          ide.localStorage(`layout.${name}`, element.layout().readState())
+        )
 
-          if (attrs.layoutDisabled != null) {
-            return scope.$watch(attrs.layoutDisabled, function(value) {
+        if (attrs.openEast != null) {
+          scope.$watch(attrs.openEast, function(value, oldValue) {
+            if (value != null && value !== oldValue) {
               if (value) {
-                element.layout().hide('east')
+                element.layout().open('east')
               } else {
-                element.layout().show('east')
+                element.layout().close('east')
               }
-              if (hasCustomToggler) {
-                return customTogglerEl.scope().$applyAsync(function() {
-                  customTogglerEl.scope().isOpen = !value
-                  return (customTogglerEl.scope().isVisible = !value)
-                })
-              }
-            })
-          }
-        },
+            }
+            return setTimeout(() => scope.$digest(), 0)
+          })
+        }
 
-        post(scope, element, attrs) {
-          const name = attrs.layout
-          const state = element.layout().readState()
-          return scope.$broadcast(`layout:${name}:linked`, state)
+        if (attrs.allowOverflowOn != null) {
+          const layoutObj = element.layout()
+          const overflowPane = scope.$eval(attrs.allowOverflowOn)
+          const overflowPaneEl = layoutObj.panes[overflowPane]
+          // Set the panel as overflowing (gives it higher z-index and sets overflow rules)
+          layoutObj.allowOverflow(overflowPane)
+          // Read the given z-index value and increment it, so that it's higher than synctex controls.
+          const overflowPaneZVal = overflowPaneEl.zIndex()
+          overflowPaneEl.css('z-index', overflowPaneZVal + 1)
         }
+
+        resetOpenStates()
+        onInternalResize()
+
+        if (attrs.layoutDisabled != null) {
+          return scope.$watch(attrs.layoutDisabled, function(value) {
+            if (value) {
+              element.layout().hide('east')
+            } else {
+              element.layout().show('east')
+            }
+            if (hasCustomToggler) {
+              return customTogglerEl.scope().$applyAsync(function() {
+                customTogglerEl.scope().isOpen = !value
+                return (customTogglerEl.scope().isVisible = !value)
+              })
+            }
+          })
+        }
+      },
+
+      post(scope, element, attrs) {
+        const name = attrs.layout
+        const state = element.layout().readState()
+        return scope.$broadcast(`layout:${name}:linked`, state)
       }
     }
-  })))
+  }
+}))

+ 10 - 8
services/web/frontend/js/ide/directives/validFile.js

@@ -9,11 +9,13 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../base', './SafePath'], (App, SafePath) =>
-  App.directive('validFile', () => ({
-    require: 'ngModel',
-    link(scope, element, attrs, ngModelCtrl) {
-      return (ngModelCtrl.$validators.validFile = filename =>
-        SafePath.isCleanFilename(filename))
-    }
-  })))
+import App from '../../base'
+import SafePath from './SafePath'
+
+export default App.directive('validFile', () => ({
+  require: 'ngModel',
+  link(scope, element, attrs, ngModelCtrl) {
+    return (ngModelCtrl.$validators.validFile = filename =>
+      SafePath.isCleanFilename(filename))
+  }
+}))

+ 624 - 630
services/web/frontend/js/ide/editor/Document.js

@@ -16,721 +16,715 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  '../../utils/EventEmitter',
-  './ShareJsDoc',
-  '../review-panel/RangesTracker'
-], function(EventEmitter, ShareJsDoc, RangesTracker) {
-  let Document
-  return (Document = (function() {
-    Document = class Document extends EventEmitter {
-      static initClass() {
-        this.prototype.MAX_PENDING_OP_SIZE = 64
-      }
-      static getDocument(ide, doc_id) {
-        if (!this.openDocs) {
-          this.openDocs = {}
-        }
-        if (this.openDocs[doc_id] == null) {
-          sl_console.log(
-            `[getDocument] Creating new document instance for ${doc_id}`
-          )
-          this.openDocs[doc_id] = new Document(ide, doc_id)
-        } else {
-          sl_console.log(
-            `[getDocument] Returning existing document instance for ${doc_id}`
-          )
-        }
-        return this.openDocs[doc_id]
-      }
-
-      static hasUnsavedChanges() {
-        const object = this.openDocs || {}
-        for (let doc_id in object) {
-          const doc = object[doc_id]
-          if (doc.hasBufferedOps()) {
-            return true
-          }
-        }
-        return false
-      }
-
-      static flushAll() {
-        return (() => {
-          const result = []
-          for (let doc_id in this.openDocs) {
-            const doc = this.openDocs[doc_id]
-            result.push(doc.flush())
-          }
-          return result
-        })()
+import EventEmitter from '../../utils/EventEmitter'
+import ShareJsDoc from './ShareJsDoc'
+import RangesTracker from '../review-panel/RangesTracker'
+let Document
+
+export default (Document = (function() {
+  Document = class Document extends EventEmitter {
+    static initClass() {
+      this.prototype.MAX_PENDING_OP_SIZE = 64
+    }
+    static getDocument(ide, doc_id) {
+      if (!this.openDocs) {
+        this.openDocs = {}
       }
-
-      constructor(ide, doc_id) {
-        super()
-        this.ide = ide
-        this.doc_id = doc_id
-        this.connected = this.ide.socket.socket.connected
-        this.joined = false
-        this.wantToBeJoined = false
-        this._checkAceConsistency = _.bind(
-          this._checkConsistency,
-          this,
-          this.ace
+      if (this.openDocs[doc_id] == null) {
+        sl_console.log(
+          `[getDocument] Creating new document instance for ${doc_id}`
+        )
+        this.openDocs[doc_id] = new Document(ide, doc_id)
+      } else {
+        sl_console.log(
+          `[getDocument] Returning existing document instance for ${doc_id}`
         )
-        this._checkCMConsistency = _.bind(this._checkConsistency, this, this.cm)
-        this._bindToEditorEvents()
-        this._bindToSocketEvents()
       }
+      return this.openDocs[doc_id]
+    }
 
-      attachToAce(ace) {
-        this.ace = ace
-        if (this.doc != null) {
-          this.doc.attachToAce(this.ace)
+    static hasUnsavedChanges() {
+      const object = this.openDocs || {}
+      for (let doc_id in object) {
+        const doc = object[doc_id]
+        if (doc.hasBufferedOps()) {
+          return true
         }
-        const editorDoc = this.ace.getSession().getDocument()
-        editorDoc.on('change', this._checkAceConsistency)
-        return this.ide.$scope.$emit('document:opened', this.doc)
       }
+      return false
+    }
 
-      detachFromAce() {
-        if (this.doc != null) {
-          this.doc.detachFromAce()
-        }
-        const editorDoc =
-          this.ace != null ? this.ace.getSession().getDocument() : undefined
-        if (editorDoc != null) {
-          editorDoc.off('change', this._checkAceConsistency)
+    static flushAll() {
+      return (() => {
+        const result = []
+        for (let doc_id in this.openDocs) {
+          const doc = this.openDocs[doc_id]
+          result.push(doc.flush())
         }
-        return this.ide.$scope.$emit('document:closed', this.doc)
-      }
+        return result
+      })()
+    }
 
-      attachToCM(cm) {
-        this.cm = cm
-        if (this.doc != null) {
-          this.doc.attachToCM(this.cm)
-        }
-        if (this.cm != null) {
-          this.cm.on('change', this._checkCMConsistency)
-        }
-        return this.ide.$scope.$emit('document:opened', this.doc)
-      }
+    constructor(ide, doc_id) {
+      super()
+      this.ide = ide
+      this.doc_id = doc_id
+      this.connected = this.ide.socket.socket.connected
+      this.joined = false
+      this.wantToBeJoined = false
+      this._checkAceConsistency = _.bind(this._checkConsistency, this, this.ace)
+      this._checkCMConsistency = _.bind(this._checkConsistency, this, this.cm)
+      this._bindToEditorEvents()
+      this._bindToSocketEvents()
+    }
 
-      detachFromCM() {
-        if (this.doc != null) {
-          this.doc.detachFromCM()
-        }
-        if (this.cm != null) {
-          this.cm.off('change', this._checkCMConsistency)
-        }
-        return this.ide.$scope.$emit('document:closed', this.doc)
-      }
-
-      submitOp(...args) {
-        return this.doc != null
-          ? this.doc.submitOp(...Array.from(args || []))
-          : undefined
-      }
-
-      _checkConsistency(editor) {
-        return () => {
-          // We've been seeing a lot of errors when I think there shouldn't be
-          // any, which may be related to this check happening before the change is
-          // applied. If we use a timeout, hopefully we can reduce this.
-          return setTimeout(() => {
-            const editorValue = editor != null ? editor.getValue() : undefined
-            const sharejsValue =
-              this.doc != null ? this.doc.getSnapshot() : undefined
-            if (editorValue !== sharejsValue) {
-              return this._onError(
-                new Error('Editor text does not match server text')
-              )
-            }
-          }, 0)
-        }
+    attachToAce(ace) {
+      this.ace = ace
+      if (this.doc != null) {
+        this.doc.attachToAce(this.ace)
       }
+      const editorDoc = this.ace.getSession().getDocument()
+      editorDoc.on('change', this._checkAceConsistency)
+      return this.ide.$scope.$emit('document:opened', this.doc)
+    }
 
-      getSnapshot() {
-        return this.doc != null ? this.doc.getSnapshot() : undefined
+    detachFromAce() {
+      if (this.doc != null) {
+        this.doc.detachFromAce()
       }
-
-      getType() {
-        return this.doc != null ? this.doc.getType() : undefined
+      const editorDoc =
+        this.ace != null ? this.ace.getSession().getDocument() : undefined
+      if (editorDoc != null) {
+        editorDoc.off('change', this._checkAceConsistency)
       }
+      return this.ide.$scope.$emit('document:closed', this.doc)
+    }
 
-      getInflightOp() {
-        return this.doc != null ? this.doc.getInflightOp() : undefined
+    attachToCM(cm) {
+      this.cm = cm
+      if (this.doc != null) {
+        this.doc.attachToCM(this.cm)
       }
-
-      getPendingOp() {
-        return this.doc != null ? this.doc.getPendingOp() : undefined
+      if (this.cm != null) {
+        this.cm.on('change', this._checkCMConsistency)
       }
+      return this.ide.$scope.$emit('document:opened', this.doc)
+    }
 
-      getRecentAck() {
-        return this.doc != null ? this.doc.getRecentAck() : undefined
+    detachFromCM() {
+      if (this.doc != null) {
+        this.doc.detachFromCM()
       }
-
-      getOpSize(op) {
-        return this.doc != null ? this.doc.getOpSize(op) : undefined
+      if (this.cm != null) {
+        this.cm.off('change', this._checkCMConsistency)
       }
+      return this.ide.$scope.$emit('document:closed', this.doc)
+    }
 
-      hasBufferedOps() {
-        return this.doc != null ? this.doc.hasBufferedOps() : undefined
-      }
+    submitOp(...args) {
+      return this.doc != null
+        ? this.doc.submitOp(...Array.from(args || []))
+        : undefined
+    }
 
-      setTrackingChanges(track_changes) {
-        return (this.doc.track_changes = track_changes)
+    _checkConsistency(editor) {
+      return () => {
+        // We've been seeing a lot of errors when I think there shouldn't be
+        // any, which may be related to this check happening before the change is
+        // applied. If we use a timeout, hopefully we can reduce this.
+        return setTimeout(() => {
+          const editorValue = editor != null ? editor.getValue() : undefined
+          const sharejsValue =
+            this.doc != null ? this.doc.getSnapshot() : undefined
+          if (editorValue !== sharejsValue) {
+            return this._onError(
+              new Error('Editor text does not match server text')
+            )
+          }
+        }, 0)
       }
+    }
 
-      getTrackingChanges() {
-        return !!this.doc.track_changes
-      }
+    getSnapshot() {
+      return this.doc != null ? this.doc.getSnapshot() : undefined
+    }
 
-      setTrackChangesIdSeeds(id_seeds) {
-        return (this.doc.track_changes_id_seeds = id_seeds)
-      }
+    getType() {
+      return this.doc != null ? this.doc.getType() : undefined
+    }
+
+    getInflightOp() {
+      return this.doc != null ? this.doc.getInflightOp() : undefined
+    }
+
+    getPendingOp() {
+      return this.doc != null ? this.doc.getPendingOp() : undefined
+    }
+
+    getRecentAck() {
+      return this.doc != null ? this.doc.getRecentAck() : undefined
+    }
 
-      _bindToSocketEvents() {
-        this._onUpdateAppliedHandler = update => this._onUpdateApplied(update)
-        this.ide.socket.on('otUpdateApplied', this._onUpdateAppliedHandler)
-        this._onErrorHandler = (error, update) => this._onError(error, update)
-        this.ide.socket.on('otUpdateError', this._onErrorHandler)
-        this._onDisconnectHandler = error => this._onDisconnect(error)
-        return this.ide.socket.on('disconnect', this._onDisconnectHandler)
+    getOpSize(op) {
+      return this.doc != null ? this.doc.getOpSize(op) : undefined
+    }
+
+    hasBufferedOps() {
+      return this.doc != null ? this.doc.hasBufferedOps() : undefined
+    }
+
+    setTrackingChanges(track_changes) {
+      return (this.doc.track_changes = track_changes)
+    }
+
+    getTrackingChanges() {
+      return !!this.doc.track_changes
+    }
+
+    setTrackChangesIdSeeds(id_seeds) {
+      return (this.doc.track_changes_id_seeds = id_seeds)
+    }
+
+    _bindToSocketEvents() {
+      this._onUpdateAppliedHandler = update => this._onUpdateApplied(update)
+      this.ide.socket.on('otUpdateApplied', this._onUpdateAppliedHandler)
+      this._onErrorHandler = (error, update) => this._onError(error, update)
+      this.ide.socket.on('otUpdateError', this._onErrorHandler)
+      this._onDisconnectHandler = error => this._onDisconnect(error)
+      return this.ide.socket.on('disconnect', this._onDisconnectHandler)
+    }
+
+    _bindToEditorEvents() {
+      const onReconnectHandler = update => {
+        return this._onReconnect(update)
       }
+      return (this._unsubscribeReconnectHandler = this.ide.$scope.$on(
+        'project:joined',
+        onReconnectHandler
+      ))
+    }
 
-      _bindToEditorEvents() {
-        const onReconnectHandler = update => {
-          return this._onReconnect(update)
+    _unBindFromEditorEvents() {
+      return this._unsubscribeReconnectHandler()
+    }
+
+    _unBindFromSocketEvents() {
+      this.ide.socket.removeListener(
+        'otUpdateApplied',
+        this._onUpdateAppliedHandler
+      )
+      this.ide.socket.removeListener('otUpdateError', this._onErrorHandler)
+      return this.ide.socket.removeListener(
+        'disconnect',
+        this._onDisconnectHandler
+      )
+    }
+
+    leaveAndCleanUp() {
+      return this.leave(error => {
+        return this._cleanUp()
+      })
+    }
+
+    join(callback) {
+      if (callback == null) {
+        callback = function(error) {}
+      }
+      this.wantToBeJoined = true
+      this._cancelLeave()
+      if (this.connected) {
+        return this._joinDoc(callback)
+      } else {
+        if (!this._joinCallbacks) {
+          this._joinCallbacks = []
         }
-        return (this._unsubscribeReconnectHandler = this.ide.$scope.$on(
-          'project:joined',
-          onReconnectHandler
-        ))
+        return this._joinCallbacks.push(callback)
       }
+    }
 
-      _unBindFromEditorEvents() {
-        return this._unsubscribeReconnectHandler()
+    leave(callback) {
+      if (callback == null) {
+        callback = function(error) {}
       }
-
-      _unBindFromSocketEvents() {
-        this.ide.socket.removeListener(
-          'otUpdateApplied',
-          this._onUpdateAppliedHandler
-        )
-        this.ide.socket.removeListener('otUpdateError', this._onErrorHandler)
-        return this.ide.socket.removeListener(
-          'disconnect',
-          this._onDisconnectHandler
+      this.wantToBeJoined = false
+      this._cancelJoin()
+      if (this.doc != null && this.doc.hasBufferedOps()) {
+        sl_console.log(
+          '[leave] Doc has buffered ops, pushing callback for later'
         )
+        if (!this._leaveCallbacks) {
+          this._leaveCallbacks = []
+        }
+        return this._leaveCallbacks.push(callback)
+      } else if (!this.connected) {
+        sl_console.log('[leave] Not connected, returning now')
+        return callback()
+      } else {
+        sl_console.log('[leave] Leaving now')
+        return this._leaveDoc(callback)
       }
+    }
 
-      leaveAndCleanUp() {
-        return this.leave(error => {
-          return this._cleanUp()
-        })
-      }
+    flush() {
+      return this.doc != null ? this.doc.flushPendingOps() : undefined
+    }
 
-      join(callback) {
-        if (callback == null) {
-          callback = function(error) {}
-        }
-        this.wantToBeJoined = true
-        this._cancelLeave()
-        if (this.connected) {
-          return this._joinDoc(callback)
-        } else {
-          if (!this._joinCallbacks) {
-            this._joinCallbacks = []
+    chaosMonkey(line, char) {
+      if (line == null) {
+        line = 0
+      }
+      if (char == null) {
+        char = 'a'
+      }
+      const orig = char
+      let copy = null
+      let pos = 0
+      var timer = () => {
+        if (copy == null || !copy.length) {
+          copy = orig.slice() + ' ' + new Date() + '\n'
+          line += Math.random() > 0.1 ? 1 : -2
+          if (line < 0) {
+            line = 0
           }
-          return this._joinCallbacks.push(callback)
-        }
+          pos = 0
+        }
+        char = copy[0]
+        copy = copy.slice(1)
+        this.ace.session.insert({ row: line, column: pos }, char)
+        pos += 1
+        return (this._cm = setTimeout(
+          timer,
+          100 + (Math.random() < 0.1 ? 1000 : 0)
+        ))
       }
+      return (this._cm = timer())
+    }
 
-      leave(callback) {
-        if (callback == null) {
-          callback = function(error) {}
-        }
-        this.wantToBeJoined = false
-        this._cancelJoin()
-        if (this.doc != null && this.doc.hasBufferedOps()) {
-          sl_console.log(
-            '[leave] Doc has buffered ops, pushing callback for later'
-          )
-          if (!this._leaveCallbacks) {
-            this._leaveCallbacks = []
-          }
-          return this._leaveCallbacks.push(callback)
-        } else if (!this.connected) {
-          sl_console.log('[leave] Not connected, returning now')
-          return callback()
-        } else {
-          sl_console.log('[leave] Leaving now')
-          return this._leaveDoc(callback)
-        }
-      }
+    clearChaosMonkey() {
+      return clearTimeout(this._cm) // pending ops bigger than this are always considered unsaved
+    }
 
-      flush() {
-        return this.doc != null ? this.doc.flushPendingOps() : undefined
+    pollSavedStatus() {
+      // returns false if doc has ops waiting to be acknowledged or
+      // sent that haven't changed since the last time we checked.
+      // Otherwise returns true.
+      let saved
+      const inflightOp = this.getInflightOp()
+      const pendingOp = this.getPendingOp()
+      const recentAck = this.getRecentAck()
+      const pendingOpSize = pendingOp != null && this.getOpSize(pendingOp)
+      if (inflightOp == null && pendingOp == null) {
+        // there's nothing going on, this is ok.
+        saved = true
+        sl_console.logOnce('[pollSavedStatus] no inflight or pending ops')
+      } else if (inflightOp != null && inflightOp === this.oldInflightOp) {
+        // The same inflight op has been sitting unacked since we
+        // last checked, this is bad.
+        saved = false
+        sl_console.log('[pollSavedStatus] inflight op is same as before')
+      } else if (
+        pendingOp != null &&
+        recentAck &&
+        pendingOpSize < this.MAX_PENDING_OP_SIZE
+      ) {
+        // There is an op waiting to go to server but it is small and
+        // within the flushDelay, this is ok for now.
+        saved = true
+        sl_console.log(
+          '[pollSavedStatus] pending op (small with recent ack) assume ok',
+          pendingOp,
+          pendingOpSize
+        )
+      } else {
+        // In any other situation, assume the document is unsaved.
+        saved = false
+        sl_console.log(
+          `[pollSavedStatus] assuming not saved (inflightOp?: ${inflightOp !=
+            null}, pendingOp?: ${pendingOp != null})`
+        )
       }
 
-      chaosMonkey(line, char) {
-        if (line == null) {
-          line = 0
-        }
-        if (char == null) {
-          char = 'a'
-        }
-        const orig = char
-        let copy = null
-        let pos = 0
-        var timer = () => {
-          if (copy == null || !copy.length) {
-            copy = orig.slice() + ' ' + new Date() + '\n'
-            line += Math.random() > 0.1 ? 1 : -2
-            if (line < 0) {
-              line = 0
-            }
-            pos = 0
-          }
-          char = copy[0]
-          copy = copy.slice(1)
-          this.ace.session.insert({ row: line, column: pos }, char)
-          pos += 1
-          return (this._cm = setTimeout(
-            timer,
-            100 + (Math.random() < 0.1 ? 1000 : 0)
-          ))
-        }
-        return (this._cm = timer())
-      }
-
-      clearChaosMonkey() {
-        return clearTimeout(this._cm) // pending ops bigger than this are always considered unsaved
-      }
-
-      pollSavedStatus() {
-        // returns false if doc has ops waiting to be acknowledged or
-        // sent that haven't changed since the last time we checked.
-        // Otherwise returns true.
-        let saved
-        const inflightOp = this.getInflightOp()
-        const pendingOp = this.getPendingOp()
-        const recentAck = this.getRecentAck()
-        const pendingOpSize = pendingOp != null && this.getOpSize(pendingOp)
-        if (inflightOp == null && pendingOp == null) {
-          // there's nothing going on, this is ok.
-          saved = true
-          sl_console.logOnce('[pollSavedStatus] no inflight or pending ops')
-        } else if (inflightOp != null && inflightOp === this.oldInflightOp) {
-          // The same inflight op has been sitting unacked since we
-          // last checked, this is bad.
-          saved = false
-          sl_console.log('[pollSavedStatus] inflight op is same as before')
-        } else if (
-          pendingOp != null &&
-          recentAck &&
-          pendingOpSize < this.MAX_PENDING_OP_SIZE
-        ) {
-          // There is an op waiting to go to server but it is small and
-          // within the flushDelay, this is ok for now.
-          saved = true
-          sl_console.log(
-            '[pollSavedStatus] pending op (small with recent ack) assume ok',
-            pendingOp,
-            pendingOpSize
-          )
-        } else {
-          // In any other situation, assume the document is unsaved.
-          saved = false
-          sl_console.log(
-            `[pollSavedStatus] assuming not saved (inflightOp?: ${inflightOp !=
-              null}, pendingOp?: ${pendingOp != null})`
-          )
-        }
+      this.oldInflightOp = inflightOp
+      return saved
+    }
 
-        this.oldInflightOp = inflightOp
-        return saved
+    _cancelLeave() {
+      if (this._leaveCallbacks != null) {
+        return delete this._leaveCallbacks
       }
+    }
 
-      _cancelLeave() {
-        if (this._leaveCallbacks != null) {
-          return delete this._leaveCallbacks
-        }
+    _cancelJoin() {
+      if (this._joinCallbacks != null) {
+        return delete this._joinCallbacks
       }
+    }
 
-      _cancelJoin() {
-        if (this._joinCallbacks != null) {
-          return delete this._joinCallbacks
+    _onUpdateApplied(update) {
+      this.ide.pushEvent('received-update', {
+        doc_id: this.doc_id,
+        remote_doc_id: update != null ? update.doc : undefined,
+        wantToBeJoined: this.wantToBeJoined,
+        update,
+        hasDoc: this.doc != null
+      })
+
+      if (
+        window.disconnectOnAck != null &&
+        Math.random() < window.disconnectOnAck
+      ) {
+        sl_console.log('Disconnecting on ack', update)
+        window._ide.socket.socket.disconnect()
+        // Pretend we never received the ack
+        return
+      }
+
+      if (window.dropAcks != null && Math.random() < window.dropAcks) {
+        if (update.op == null) {
+          // Only drop our own acks, not collaborator updates
+          sl_console.log('Simulating a lost ack', update)
+          return
         }
       }
 
-      _onUpdateApplied(update) {
-        this.ide.pushEvent('received-update', {
-          doc_id: this.doc_id,
-          remote_doc_id: update != null ? update.doc : undefined,
-          wantToBeJoined: this.wantToBeJoined,
-          update,
-          hasDoc: this.doc != null
+      if (
+        (update != null ? update.doc : undefined) === this.doc_id &&
+        this.doc != null
+      ) {
+        this.ide.pushEvent('received-update:processing', {
+          update
         })
+        // FIXME: change this back to processUpdateFromServer when redis fixed
+        this.doc.processUpdateFromServerInOrder(update)
 
-        if (
-          window.disconnectOnAck != null &&
-          Math.random() < window.disconnectOnAck
-        ) {
-          sl_console.log('Disconnecting on ack', update)
-          window._ide.socket.socket.disconnect()
-          // Pretend we never received the ack
-          return
+        if (!this.wantToBeJoined) {
+          return this.leave()
         }
+      }
+    }
 
-        if (window.dropAcks != null && Math.random() < window.dropAcks) {
-          if (update.op == null) {
-            // Only drop our own acks, not collaborator updates
-            sl_console.log('Simulating a lost ack', update)
-            return
-          }
-        }
+    _onDisconnect() {
+      sl_console.log('[onDisconnect] disconnecting')
+      this.connected = false
+      this.joined = false
+      return this.doc != null
+        ? this.doc.updateConnectionState('disconnected')
+        : undefined
+    }
 
-        if (
-          (update != null ? update.doc : undefined) === this.doc_id &&
-          this.doc != null
-        ) {
-          this.ide.pushEvent('received-update:processing', {
-            update
-          })
-          // FIXME: change this back to processUpdateFromServer when redis fixed
-          this.doc.processUpdateFromServerInOrder(update)
-
-          if (!this.wantToBeJoined) {
-            return this.leave()
+    _onReconnect() {
+      sl_console.log('[onReconnect] reconnected (joined project)')
+      this.ide.pushEvent('reconnected:afterJoinProject')
+
+      this.connected = true
+      if (
+        this.wantToBeJoined ||
+        (this.doc != null ? this.doc.hasBufferedOps() : undefined)
+      ) {
+        sl_console.log(
+          `[onReconnect] Rejoining (wantToBeJoined: ${
+            this.wantToBeJoined
+          } OR hasBufferedOps: ${
+            this.doc != null ? this.doc.hasBufferedOps() : undefined
+          })`
+        )
+        return this._joinDoc(error => {
+          if (error != null) {
+            return this._onError(error)
           }
-        }
+          this.doc.updateConnectionState('ok')
+          this.doc.flushPendingOps()
+          return this._callJoinCallbacks()
+        })
       }
+    }
 
-      _onDisconnect() {
-        sl_console.log('[onDisconnect] disconnecting')
-        this.connected = false
-        this.joined = false
-        return this.doc != null
-          ? this.doc.updateConnectionState('disconnected')
-          : undefined
-      }
-
-      _onReconnect() {
-        sl_console.log('[onReconnect] reconnected (joined project)')
-        this.ide.pushEvent('reconnected:afterJoinProject')
-
-        this.connected = true
-        if (
-          this.wantToBeJoined ||
-          (this.doc != null ? this.doc.hasBufferedOps() : undefined)
-        ) {
-          sl_console.log(
-            `[onReconnect] Rejoining (wantToBeJoined: ${
-              this.wantToBeJoined
-            } OR hasBufferedOps: ${
-              this.doc != null ? this.doc.hasBufferedOps() : undefined
-            })`
-          )
-          return this._joinDoc(error => {
-            if (error != null) {
-              return this._onError(error)
-            }
-            this.doc.updateConnectionState('ok')
-            this.doc.flushPendingOps()
-            return this._callJoinCallbacks()
-          })
-        }
+    _callJoinCallbacks() {
+      for (let callback of Array.from(this._joinCallbacks || [])) {
+        callback()
       }
+      return delete this._joinCallbacks
+    }
 
-      _callJoinCallbacks() {
-        for (let callback of Array.from(this._joinCallbacks || [])) {
-          callback()
-        }
-        return delete this._joinCallbacks
+    _joinDoc(callback) {
+      if (callback == null) {
+        callback = function(error) {}
       }
-
-      _joinDoc(callback) {
-        if (callback == null) {
-          callback = function(error) {}
-        }
-        if (this.doc != null) {
-          this.ide.pushEvent('joinDoc:existing', {
-            doc_id: this.doc_id,
-            version: this.doc.getVersion()
-          })
-          return this.ide.socket.emit(
-            'joinDoc',
-            this.doc_id,
-            this.doc.getVersion(),
-            { encodeRanges: true },
-            (error, docLines, version, updates, ranges) => {
-              if (error != null) {
-                return callback(error)
-              }
-              this.joined = true
-              this.doc.catchUp(updates)
-              this._decodeRanges(ranges)
-              this._catchUpRanges(
-                ranges != null ? ranges.changes : undefined,
-                ranges != null ? ranges.comments : undefined
-              )
-              return callback()
-            }
-          )
-        } else {
-          this.ide.pushEvent('joinDoc:new', {
-            doc_id: this.doc_id
-          })
-          return this.ide.socket.emit(
-            'joinDoc',
-            this.doc_id,
-            { encodeRanges: true },
-            (error, docLines, version, updates, ranges) => {
-              if (error != null) {
-                return callback(error)
-              }
-              this.joined = true
-              this.ide.pushEvent('joinDoc:inited', {
-                doc_id: this.doc_id,
-                version
-              })
-              this.doc = new ShareJsDoc(
-                this.doc_id,
-                docLines,
-                version,
-                this.ide.socket
-              )
-              this._decodeRanges(ranges)
-              this.ranges = new RangesTracker(
-                ranges != null ? ranges.changes : undefined,
-                ranges != null ? ranges.comments : undefined
-              )
-              this._bindToShareJsDocEvents()
-              return callback()
-            }
-          )
-        }
-      }
-
-      _decodeRanges(ranges) {
-        const decodeFromWebsockets = text => decodeURIComponent(escape(text))
-        try {
-          for (let change of Array.from(ranges.changes || [])) {
-            if (change.op.i != null) {
-              change.op.i = decodeFromWebsockets(change.op.i)
-            }
-            if (change.op.d != null) {
-              change.op.d = decodeFromWebsockets(change.op.d)
+      if (this.doc != null) {
+        this.ide.pushEvent('joinDoc:existing', {
+          doc_id: this.doc_id,
+          version: this.doc.getVersion()
+        })
+        return this.ide.socket.emit(
+          'joinDoc',
+          this.doc_id,
+          this.doc.getVersion(),
+          { encodeRanges: true },
+          (error, docLines, version, updates, ranges) => {
+            if (error != null) {
+              return callback(error)
             }
+            this.joined = true
+            this.doc.catchUp(updates)
+            this._decodeRanges(ranges)
+            this._catchUpRanges(
+              ranges != null ? ranges.changes : undefined,
+              ranges != null ? ranges.comments : undefined
+            )
+            return callback()
           }
-          return (() => {
-            const result = []
-            for (let comment of Array.from(ranges.comments || [])) {
-              if (comment.op.c != null) {
-                result.push((comment.op.c = decodeFromWebsockets(comment.op.c)))
-              } else {
-                result.push(undefined)
-              }
+        )
+      } else {
+        this.ide.pushEvent('joinDoc:new', {
+          doc_id: this.doc_id
+        })
+        return this.ide.socket.emit(
+          'joinDoc',
+          this.doc_id,
+          { encodeRanges: true },
+          (error, docLines, version, updates, ranges) => {
+            if (error != null) {
+              return callback(error)
             }
-            return result
-          })()
-        } catch (err) {
-          return console.log(err)
-        }
+            this.joined = true
+            this.ide.pushEvent('joinDoc:inited', {
+              doc_id: this.doc_id,
+              version
+            })
+            this.doc = new ShareJsDoc(
+              this.doc_id,
+              docLines,
+              version,
+              this.ide.socket
+            )
+            this._decodeRanges(ranges)
+            this.ranges = new RangesTracker(
+              ranges != null ? ranges.changes : undefined,
+              ranges != null ? ranges.comments : undefined
+            )
+            this._bindToShareJsDocEvents()
+            return callback()
+          }
+        )
       }
+    }
 
-      _leaveDoc(callback) {
-        if (callback == null) {
-          callback = function(error) {}
-        }
-        sl_console.log('[_leaveDoc] Sending leaveDoc request')
-        return this.ide.socket.emit('leaveDoc', this.doc_id, error => {
-          if (error != null) {
-            return callback(error)
+    _decodeRanges(ranges) {
+      const decodeFromWebsockets = text => decodeURIComponent(escape(text))
+      try {
+        for (let change of Array.from(ranges.changes || [])) {
+          if (change.op.i != null) {
+            change.op.i = decodeFromWebsockets(change.op.i)
           }
-          this.joined = false
-          for (callback of Array.from(this._leaveCallbacks || [])) {
-            sl_console.log('[_leaveDoc] Calling buffered callback', callback)
-            callback(error)
+          if (change.op.d != null) {
+            change.op.d = decodeFromWebsockets(change.op.d)
           }
-          delete this._leaveCallbacks
-          return callback(error)
-        })
+        }
+        return (() => {
+          const result = []
+          for (let comment of Array.from(ranges.comments || [])) {
+            if (comment.op.c != null) {
+              result.push((comment.op.c = decodeFromWebsockets(comment.op.c)))
+            } else {
+              result.push(undefined)
+            }
+          }
+          return result
+        })()
+      } catch (err) {
+        return console.log(err)
       }
+    }
 
-      _cleanUp() {
-        if (Document.openDocs[this.doc_id] === this) {
-          sl_console.log(
-            `[_cleanUp] Removing self (${this.doc_id}) from in openDocs`
-          )
-          delete Document.openDocs[this.doc_id]
-        } else {
-          // It's possible that this instance has error, and the doc has been reloaded.
-          // This creates a new instance in Document.openDoc with the same id. We shouldn't
-          // clear it because it's not this instance.
-          sl_console.log(
-            `[_cleanUp] New instance of (${this.doc_id}) created. Not removing`
-          )
+    _leaveDoc(callback) {
+      if (callback == null) {
+        callback = function(error) {}
+      }
+      sl_console.log('[_leaveDoc] Sending leaveDoc request')
+      return this.ide.socket.emit('leaveDoc', this.doc_id, error => {
+        if (error != null) {
+          return callback(error)
         }
-        this._unBindFromEditorEvents()
-        return this._unBindFromSocketEvents()
+        this.joined = false
+        for (callback of Array.from(this._leaveCallbacks || [])) {
+          sl_console.log('[_leaveDoc] Calling buffered callback', callback)
+          callback(error)
+        }
+        delete this._leaveCallbacks
+        return callback(error)
+      })
+    }
+
+    _cleanUp() {
+      if (Document.openDocs[this.doc_id] === this) {
+        sl_console.log(
+          `[_cleanUp] Removing self (${this.doc_id}) from in openDocs`
+        )
+        delete Document.openDocs[this.doc_id]
+      } else {
+        // It's possible that this instance has error, and the doc has been reloaded.
+        // This creates a new instance in Document.openDoc with the same id. We shouldn't
+        // clear it because it's not this instance.
+        sl_console.log(
+          `[_cleanUp] New instance of (${this.doc_id}) created. Not removing`
+        )
       }
+      this._unBindFromEditorEvents()
+      return this._unBindFromSocketEvents()
+    }
 
-      _bindToShareJsDocEvents() {
-        this.doc.on('error', (error, meta) => this._onError(error, meta))
-        this.doc.on('externalUpdate', update => {
-          this.ide.pushEvent('externalUpdate', { doc_id: this.doc_id })
-          return this.trigger('externalUpdate', update)
-        })
-        this.doc.on('remoteop', (...args) => {
-          this.ide.pushEvent('remoteop', { doc_id: this.doc_id })
-          return this.trigger('remoteop', ...Array.from(args))
-        })
-        this.doc.on('op:sent', op => {
-          this.ide.pushEvent('op:sent', {
-            doc_id: this.doc_id,
-            op
-          })
-          return this.trigger('op:sent')
-        })
-        this.doc.on('op:acknowledged', op => {
-          this.ide.pushEvent('op:acknowledged', {
-            doc_id: this.doc_id,
-            op
-          })
-          this.ide.$scope.$emit('ide:opAcknowledged', {
-            doc_id: this.doc_id,
-            op
-          })
-          return this.trigger('op:acknowledged')
-        })
-        this.doc.on('op:timeout', op => {
-          this.ide.pushEvent('op:timeout', {
-            doc_id: this.doc_id,
-            op
-          })
-          this.trigger('op:timeout')
-          return this._onError(new Error('op timed out'), { op })
+    _bindToShareJsDocEvents() {
+      this.doc.on('error', (error, meta) => this._onError(error, meta))
+      this.doc.on('externalUpdate', update => {
+        this.ide.pushEvent('externalUpdate', { doc_id: this.doc_id })
+        return this.trigger('externalUpdate', update)
+      })
+      this.doc.on('remoteop', (...args) => {
+        this.ide.pushEvent('remoteop', { doc_id: this.doc_id })
+        return this.trigger('remoteop', ...Array.from(args))
+      })
+      this.doc.on('op:sent', op => {
+        this.ide.pushEvent('op:sent', {
+          doc_id: this.doc_id,
+          op
         })
-        this.doc.on('flush', (inflightOp, pendingOp, version) => {
-          return this.ide.pushEvent('flush', {
-            doc_id: this.doc_id,
-            inflightOp,
-            pendingOp,
-            v: version
-          })
+        return this.trigger('op:sent')
+      })
+      this.doc.on('op:acknowledged', op => {
+        this.ide.pushEvent('op:acknowledged', {
+          doc_id: this.doc_id,
+          op
         })
-        this.doc.on('change', (ops, oldSnapshot, msg) => {
-          this._applyOpsToRanges(ops, oldSnapshot, msg)
-          return this.ide.$scope.$emit('doc:changed', { doc_id: this.doc_id })
+        this.ide.$scope.$emit('ide:opAcknowledged', {
+          doc_id: this.doc_id,
+          op
         })
-        this.doc.on('flipped_pending_to_inflight', () => {
-          return this.trigger('flipped_pending_to_inflight')
+        return this.trigger('op:acknowledged')
+      })
+      this.doc.on('op:timeout', op => {
+        this.ide.pushEvent('op:timeout', {
+          doc_id: this.doc_id,
+          op
         })
-        return this.doc.on('saved', () => {
-          return this.ide.$scope.$emit('doc:saved', { doc_id: this.doc_id })
+        this.trigger('op:timeout')
+        return this._onError(new Error('op timed out'), { op })
+      })
+      this.doc.on('flush', (inflightOp, pendingOp, version) => {
+        return this.ide.pushEvent('flush', {
+          doc_id: this.doc_id,
+          inflightOp,
+          pendingOp,
+          v: version
         })
-      }
+      })
+      this.doc.on('change', (ops, oldSnapshot, msg) => {
+        this._applyOpsToRanges(ops, oldSnapshot, msg)
+        return this.ide.$scope.$emit('doc:changed', { doc_id: this.doc_id })
+      })
+      this.doc.on('flipped_pending_to_inflight', () => {
+        return this.trigger('flipped_pending_to_inflight')
+      })
+      return this.doc.on('saved', () => {
+        return this.ide.$scope.$emit('doc:saved', { doc_id: this.doc_id })
+      })
+    }
 
-      _onError(error, meta) {
-        if (meta == null) {
-          meta = {}
-        }
-        meta.doc_id = this.doc_id
-        sl_console.log('ShareJS error', error, meta)
-        if (error.message === 'no project_id found on client') {
-          sl_console.log('ignoring error, will wait to join project')
-          return
-        }
-        if (typeof ga === 'function') {
-          // sanitise the error message before sending (the "delete component"
-          // error in public/js/libs/sharejs.js includes the some document
-          // content).
-          let message = error.message
-          if (/^Delete component/.test(message)) {
-            message = 'Delete component does not match deleted text'
-          }
-          ga(
-            'send',
-            'event',
-            'error',
-            'shareJsError',
-            `${message} - ${this.ide.socket.socket.transport.name}`
-          )
-        }
-        if (this.doc != null) {
-          this.doc.clearInflightAndPendingOps()
-        }
-        this.trigger('error', error, meta)
-        // The clean up should run after the error is triggered because the error triggers a
-        // disconnect. If we run the clean up first, we remove our event handlers and miss
-        // the disconnect event, which means we try to leaveDoc when the connection comes back.
-        // This could intefere with the new connection of a new instance of this document.
-        return this._cleanUp()
+    _onError(error, meta) {
+      if (meta == null) {
+        meta = {}
+      }
+      meta.doc_id = this.doc_id
+      sl_console.log('ShareJS error', error, meta)
+      if (error.message === 'no project_id found on client') {
+        sl_console.log('ignoring error, will wait to join project')
+        return
+      }
+      if (typeof ga === 'function') {
+        // sanitise the error message before sending (the "delete component"
+        // error in public/js/libs/sharejs.js includes the some document
+        // content).
+        let message = error.message
+        if (/^Delete component/.test(message)) {
+          message = 'Delete component does not match deleted text'
+        }
+        ga(
+          'send',
+          'event',
+          'error',
+          'shareJsError',
+          `${message} - ${this.ide.socket.socket.transport.name}`
+        )
       }
+      if (this.doc != null) {
+        this.doc.clearInflightAndPendingOps()
+      }
+      this.trigger('error', error, meta)
+      // The clean up should run after the error is triggered because the error triggers a
+      // disconnect. If we run the clean up first, we remove our event handlers and miss
+      // the disconnect event, which means we try to leaveDoc when the connection comes back.
+      // This could intefere with the new connection of a new instance of this document.
+      return this._cleanUp()
+    }
 
-      _applyOpsToRanges(ops, oldSnapshot, msg) {
-        let old_id_seed
-        if (ops == null) {
-          ops = []
-        }
-        let track_changes_as = null
-        const remote_op = msg != null
-        if (__guard__(msg != null ? msg.meta : undefined, x => x.tc) != null) {
-          old_id_seed = this.ranges.getIdSeed()
-          this.ranges.setIdSeed(msg.meta.tc)
-        }
-        if (remote_op && (msg.meta != null ? msg.meta.tc : undefined)) {
-          track_changes_as = msg.meta.user_id
-        } else if (!remote_op && this.track_changes_as != null) {
-          ;({ track_changes_as } = this)
-        }
-        this.ranges.track_changes = track_changes_as != null
-        for (let op of Array.from(ops)) {
-          this.ranges.applyOp(op, { user_id: track_changes_as })
-        }
-        if (old_id_seed != null) {
-          this.ranges.setIdSeed(old_id_seed)
-        }
-        if (remote_op) {
-          // With remote ops, Ace hasn't been updated when we receive this op,
-          // so defer updating track changes until it has
-          return setTimeout(() => this.emit('ranges:dirty'))
-        } else {
-          return this.emit('ranges:dirty')
-        }
+    _applyOpsToRanges(ops, oldSnapshot, msg) {
+      let old_id_seed
+      if (ops == null) {
+        ops = []
+      }
+      let track_changes_as = null
+      const remote_op = msg != null
+      if (__guard__(msg != null ? msg.meta : undefined, x => x.tc) != null) {
+        old_id_seed = this.ranges.getIdSeed()
+        this.ranges.setIdSeed(msg.meta.tc)
       }
+      if (remote_op && (msg.meta != null ? msg.meta.tc : undefined)) {
+        track_changes_as = msg.meta.user_id
+      } else if (!remote_op && this.track_changes_as != null) {
+        ;({ track_changes_as } = this)
+      }
+      this.ranges.track_changes = track_changes_as != null
+      for (let op of Array.from(ops)) {
+        this.ranges.applyOp(op, { user_id: track_changes_as })
+      }
+      if (old_id_seed != null) {
+        this.ranges.setIdSeed(old_id_seed)
+      }
+      if (remote_op) {
+        // With remote ops, Ace hasn't been updated when we receive this op,
+        // so defer updating track changes until it has
+        return setTimeout(() => this.emit('ranges:dirty'))
+      } else {
+        return this.emit('ranges:dirty')
+      }
+    }
 
-      _catchUpRanges(changes, comments) {
-        // We've just been given the current server's ranges, but need to apply any local ops we have.
-        // Reset to the server state then apply our local ops again.
-        if (changes == null) {
-          changes = []
-        }
-        if (comments == null) {
-          comments = []
-        }
-        this.emit('ranges:clear')
-        this.ranges.changes = changes
-        this.ranges.comments = comments
-        this.ranges.track_changes = this.doc.track_changes
-        for (var op of Array.from(this.doc.getInflightOp() || [])) {
-          this.ranges.setIdSeed(this.doc.track_changes_id_seeds.inflight)
-          this.ranges.applyOp(op, { user_id: this.track_changes_as })
-        }
-        for (op of Array.from(this.doc.getPendingOp() || [])) {
-          this.ranges.setIdSeed(this.doc.track_changes_id_seeds.pending)
-          this.ranges.applyOp(op, { user_id: this.track_changes_as })
-        }
-        return this.emit('ranges:redraw')
+    _catchUpRanges(changes, comments) {
+      // We've just been given the current server's ranges, but need to apply any local ops we have.
+      // Reset to the server state then apply our local ops again.
+      if (changes == null) {
+        changes = []
+      }
+      if (comments == null) {
+        comments = []
+      }
+      this.emit('ranges:clear')
+      this.ranges.changes = changes
+      this.ranges.comments = comments
+      this.ranges.track_changes = this.doc.track_changes
+      for (var op of Array.from(this.doc.getInflightOp() || [])) {
+        this.ranges.setIdSeed(this.doc.track_changes_id_seeds.inflight)
+        this.ranges.applyOp(op, { user_id: this.track_changes_as })
+      }
+      for (op of Array.from(this.doc.getPendingOp() || [])) {
+        this.ranges.setIdSeed(this.doc.track_changes_id_seeds.pending)
+        this.ranges.applyOp(op, { user_id: this.track_changes_as })
       }
+      return this.emit('ranges:redraw')
     }
-    Document.initClass()
-    return Document
-  })())
-})
+  }
+  Document.initClass()
+  return Document
+})())
 
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null

+ 269 - 272
services/web/frontend/js/ide/editor/EditorManager.js

@@ -13,321 +13,318 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  './Document',
-  './components/spellMenu',
-  './directives/aceEditor',
-  './directives/toggleSwitch',
-  './controllers/SavingNotificationController'
-], function(Document) {
-  let EditorManager
-  return (EditorManager = (function() {
-    EditorManager = class EditorManager {
-      static initClass() {
-        this.prototype._syncTimeout = null
+import Document from './Document'
+import './components/spellMenu'
+import './directives/aceEditor'
+import './directives/toggleSwitch'
+import './controllers/SavingNotificationController'
+let EditorManager
+
+export default (EditorManager = (function() {
+  EditorManager = class EditorManager {
+    static initClass() {
+      this.prototype._syncTimeout = null
+    }
+    constructor(ide, $scope, localStorage) {
+      this.ide = ide
+      this.$scope = $scope
+      this.localStorage = localStorage
+      this.$scope.editor = {
+        sharejs_doc: null,
+        open_doc_id: null,
+        open_doc_name: null,
+        opening: true,
+        trackChanges: false,
+        wantTrackChanges: false,
+        showRichText: this.showRichText()
       }
-      constructor(ide, $scope, localStorage) {
-        this.ide = ide
-        this.$scope = $scope
-        this.localStorage = localStorage
-        this.$scope.editor = {
-          sharejs_doc: null,
-          open_doc_id: null,
-          open_doc_name: null,
-          opening: true,
-          trackChanges: false,
-          wantTrackChanges: false,
-          showRichText: this.showRichText()
+
+      this.$scope.$on('entity:selected', (event, entity) => {
+        if (this.$scope.ui.view !== 'history' && entity.type === 'doc') {
+          return this.openDoc(entity)
         }
+      })
 
-        this.$scope.$on('entity:selected', (event, entity) => {
-          if (this.$scope.ui.view !== 'history' && entity.type === 'doc') {
-            return this.openDoc(entity)
+      this.$scope.$on('entity:deleted', (event, entity) => {
+        if (this.$scope.editor.open_doc_id === entity.id) {
+          if (!this.$scope.project.rootDoc_id) {
+            this.$scope.ui.view = null
+            return
           }
-        })
-
-        this.$scope.$on('entity:deleted', (event, entity) => {
-          if (this.$scope.editor.open_doc_id === entity.id) {
-            if (!this.$scope.project.rootDoc_id) {
-              this.$scope.ui.view = null
-              return
-            }
-            const doc = this.ide.fileTreeManager.findEntityById(
-              this.$scope.project.rootDoc_id
-            )
-            if (doc == null) {
-              this.$scope.ui.view = null
-              return
-            }
-            return this.openDoc(doc)
+          const doc = this.ide.fileTreeManager.findEntityById(
+            this.$scope.project.rootDoc_id
+          )
+          if (doc == null) {
+            this.$scope.ui.view = null
+            return
           }
-        })
+          return this.openDoc(doc)
+        }
+      })
 
-        let initialized = false
-        this.$scope.$on('file-tree:initialized', () => {
-          if (!initialized) {
-            initialized = true
-            return this.autoOpenDoc()
-          }
-        })
+      let initialized = false
+      this.$scope.$on('file-tree:initialized', () => {
+        if (!initialized) {
+          initialized = true
+          return this.autoOpenDoc()
+        }
+      })
 
-        this.$scope.$on('flush-changes', () => {
-          return Document.flushAll()
-        })
-        window.addEventListener('blur', () => {
-          // The browser may put the tab into sleep as it looses focus.
-          // Flushing the documents should help with keeping the documents in
-          //  sync: we can use any new version of the doc that the server may
-          //  present us. There should be no need to insert local changes into
-          //  the doc history as the user comes back.
-          sl_console.log('[EditorManager] forcing flush onblur')
-          Document.flushAll()
-        })
+      this.$scope.$on('flush-changes', () => {
+        return Document.flushAll()
+      })
+      window.addEventListener('blur', () => {
+        // The browser may put the tab into sleep as it looses focus.
+        // Flushing the documents should help with keeping the documents in
+        //  sync: we can use any new version of the doc that the server may
+        //  present us. There should be no need to insert local changes into
+        //  the doc history as the user comes back.
+        sl_console.log('[EditorManager] forcing flush onblur')
+        Document.flushAll()
+      })
 
-        this.$scope.$watch('editor.wantTrackChanges', value => {
-          if (value == null) {
-            return
-          }
-          return this._syncTrackChangesState(this.$scope.editor.sharejs_doc)
-        })
+      this.$scope.$watch('editor.wantTrackChanges', value => {
+        if (value == null) {
+          return
+        }
+        return this._syncTrackChangesState(this.$scope.editor.sharejs_doc)
+      })
+    }
+
+    showRichText() {
+      return (
+        this.localStorage(`editor.mode.${this.$scope.project_id}`) ===
+        'rich-text'
+      )
+    }
+
+    autoOpenDoc() {
+      const open_doc_id =
+        this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`) ||
+        this.$scope.project.rootDoc_id
+      if (open_doc_id == null) {
+        return
+      }
+      const doc = this.ide.fileTreeManager.findEntityById(open_doc_id)
+      if (doc == null) {
+        return
       }
+      return this.openDoc(doc)
+    }
 
-      showRichText() {
-        return (
-          this.localStorage(`editor.mode.${this.$scope.project_id}`) ===
-          'rich-text'
-        )
+    openDocId(doc_id, options) {
+      if (options == null) {
+        options = {}
       }
+      const doc = this.ide.fileTreeManager.findEntityById(doc_id)
+      if (doc == null) {
+        return
+      }
+      return this.openDoc(doc, options)
+    }
 
-      autoOpenDoc() {
-        const open_doc_id =
-          this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`) ||
-          this.$scope.project.rootDoc_id
-        if (open_doc_id == null) {
-          return
-        }
-        const doc = this.ide.fileTreeManager.findEntityById(open_doc_id)
-        if (doc == null) {
-          return
-        }
-        return this.openDoc(doc)
+    openDoc(doc, options) {
+      if (options == null) {
+        options = {}
       }
+      sl_console.log(`[openDoc] Opening ${doc.id}`)
+      this.$scope.ui.view = 'editor'
 
-      openDocId(doc_id, options) {
-        if (options == null) {
-          options = {}
-        }
-        const doc = this.ide.fileTreeManager.findEntityById(doc_id)
-        if (doc == null) {
-          return
+      const done = () => {
+        if (options.gotoLine != null) {
+          // allow Ace to display document before moving, delay until next tick
+          // added delay to make this happen later that gotoStoredPosition in
+          // CursorPositionManager
+          return setTimeout(() => {
+            return this.$scope.$broadcast(
+              'editor:gotoLine',
+              options.gotoLine,
+              options.gotoColumn
+            )
+          }, 0)
+        } else if (options.gotoOffset != null) {
+          return setTimeout(() => {
+            return this.$scope.$broadcast(
+              'editor:gotoOffset',
+              options.gotoOffset
+            )
+          }, 0)
         }
-        return this.openDoc(doc, options)
       }
 
-      openDoc(doc, options) {
-        if (options == null) {
-          options = {}
-        }
-        sl_console.log(`[openDoc] Opening ${doc.id}`)
-        this.$scope.ui.view = 'editor'
+      // If we already have the document open we can return at this point.
+      // Note: only use forceReopen:true to override this when the document is
+      // is out of sync and needs to be reloaded from the server.
+      if (doc.id === this.$scope.editor.open_doc_id && !options.forceReopen) {
+        // automatically update the file tree whenever the file is opened
+        this.ide.fileTreeManager.selectEntity(doc)
+        this.$scope.$apply(() => {
+          return done()
+        })
+        return
+      }
 
-        const done = () => {
-          if (options.gotoLine != null) {
-            // allow Ace to display document before moving, delay until next tick
-            // added delay to make this happen later that gotoStoredPosition in
-            // CursorPositionManager
-            return setTimeout(() => {
-              return this.$scope.$broadcast(
-                'editor:gotoLine',
-                options.gotoLine,
-                options.gotoColumn
-              )
-            }, 0)
-          } else if (options.gotoOffset != null) {
-            return setTimeout(() => {
-              return this.$scope.$broadcast(
-                'editor:gotoOffset',
-                options.gotoOffset
-              )
-            }, 0)
-          }
-        }
+      // We're now either opening a new document or reloading a broken one.
+      this.$scope.editor.open_doc_id = doc.id
+      this.$scope.editor.open_doc_name = doc.name
 
-        // If we already have the document open we can return at this point.
-        // Note: only use forceReopen:true to override this when the document is
-        // is out of sync and needs to be reloaded from the server.
-        if (doc.id === this.$scope.editor.open_doc_id && !options.forceReopen) {
-          // automatically update the file tree whenever the file is opened
-          this.ide.fileTreeManager.selectEntity(doc)
-          this.$scope.$apply(() => {
-            return done()
-          })
+      this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`, doc.id)
+      this.ide.fileTreeManager.selectEntity(doc)
+
+      this.$scope.editor.opening = true
+      return this._openNewDocument(doc, (error, sharejs_doc) => {
+        if (error != null) {
+          this.ide.showGenericMessageModal(
+            'Error opening document',
+            'Sorry, something went wrong opening this document. Please try again.'
+          )
           return
         }
 
-        // We're now either opening a new document or reloading a broken one.
-        this.$scope.editor.open_doc_id = doc.id
-        this.$scope.editor.open_doc_name = doc.name
+        this._syncTrackChangesState(sharejs_doc)
 
-        this.ide.localStorage(`doc.open_id.${this.$scope.project_id}`, doc.id)
-        this.ide.fileTreeManager.selectEntity(doc)
+        this.$scope.$broadcast('doc:opened')
 
-        this.$scope.editor.opening = true
-        return this._openNewDocument(doc, (error, sharejs_doc) => {
-          if (error != null) {
-            this.ide.showGenericMessageModal(
-              'Error opening document',
-              'Sorry, something went wrong opening this document. Please try again.'
-            )
-            return
-          }
+        return this.$scope.$apply(() => {
+          this.$scope.editor.opening = false
+          this.$scope.editor.sharejs_doc = sharejs_doc
+          return done()
+        })
+      })
+    }
 
-          this._syncTrackChangesState(sharejs_doc)
+    _openNewDocument(doc, callback) {
+      if (callback == null) {
+        callback = function(error, sharejs_doc) {}
+      }
+      sl_console.log('[_openNewDocument] Opening...')
+      const current_sharejs_doc = this.$scope.editor.sharejs_doc
+      const new_sharejs_doc = Document.getDocument(this.ide, doc.id)
+      // Leave the current document only when we are opening a different new
+      // one, to avoid race conditions between leaving and joining the same
+      // document.
+      if (
+        current_sharejs_doc != null &&
+        current_sharejs_doc !== new_sharejs_doc
+      ) {
+        sl_console.log('[_openNewDocument] Leaving existing open doc...')
+        current_sharejs_doc.leaveAndCleanUp()
+        this._unbindFromDocumentEvents(current_sharejs_doc)
+      }
 
-          this.$scope.$broadcast('doc:opened')
+      return new_sharejs_doc.join(error => {
+        if (error != null) {
+          return callback(error)
+        }
+        this._bindToDocumentEvents(doc, new_sharejs_doc)
+        return callback(null, new_sharejs_doc)
+      })
+    }
 
-          return this.$scope.$apply(() => {
-            this.$scope.editor.opening = false
-            this.$scope.editor.sharejs_doc = sharejs_doc
-            return done()
-          })
+    _bindToDocumentEvents(doc, sharejs_doc) {
+      sharejs_doc.on('error', (error, meta) => {
+        let message
+        if ((error != null ? error.message : undefined) != null) {
+          ;({ message } = error)
+        } else if (typeof error === 'string') {
+          message = error
+        } else {
+          message = ''
+        }
+        if (/maxDocLength/.test(message)) {
+          this.ide.showGenericMessageModal(
+            'Document Too Long',
+            'Sorry, this file is too long to be edited manually. Please upload it directly.'
+          )
+        } else if (/too many comments or tracked changes/.test(message)) {
+          this.ide.showGenericMessageModal(
+            'Too many comments or tracked changes',
+            'Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.'
+          )
+        } else {
+          this.ide.socket.disconnect()
+          this.ide.reportError(error, meta)
+          this.ide.showOutOfSyncModal(
+            'Out of sync',
+            "Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>",
+            sharejs_doc.doc._doc.snapshot
+          )
+        }
+        const removeHandler = this.$scope.$on('project:joined', () => {
+          this.openDoc(doc, { forceReopen: true })
+          removeHandler()
         })
-      }
+      })
 
-      _openNewDocument(doc, callback) {
-        if (callback == null) {
-          callback = function(error, sharejs_doc) {}
+      return sharejs_doc.on('externalUpdate', update => {
+        if (this._ignoreExternalUpdates) {
+          return
         }
-        sl_console.log('[_openNewDocument] Opening...')
-        const current_sharejs_doc = this.$scope.editor.sharejs_doc
-        const new_sharejs_doc = Document.getDocument(this.ide, doc.id)
-        // Leave the current document only when we are opening a different new
-        // one, to avoid race conditions between leaving and joining the same
-        // document.
         if (
-          current_sharejs_doc != null &&
-          current_sharejs_doc !== new_sharejs_doc
+          _.property(['meta', 'type'])(update) === 'external' &&
+          _.property(['meta', 'source'])(update) === 'git-bridge'
         ) {
-          sl_console.log('[_openNewDocument] Leaving existing open doc...')
-          current_sharejs_doc.leaveAndCleanUp()
-          this._unbindFromDocumentEvents(current_sharejs_doc)
+          return
         }
+        return this.ide.showGenericMessageModal(
+          'Document Updated Externally',
+          'This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history.'
+        )
+      })
+    }
 
-        return new_sharejs_doc.join(error => {
-          if (error != null) {
-            return callback(error)
-          }
-          this._bindToDocumentEvents(doc, new_sharejs_doc)
-          return callback(null, new_sharejs_doc)
-        })
-      }
-
-      _bindToDocumentEvents(doc, sharejs_doc) {
-        sharejs_doc.on('error', (error, meta) => {
-          let message
-          if ((error != null ? error.message : undefined) != null) {
-            ;({ message } = error)
-          } else if (typeof error === 'string') {
-            message = error
-          } else {
-            message = ''
-          }
-          if (/maxDocLength/.test(message)) {
-            this.ide.showGenericMessageModal(
-              'Document Too Long',
-              'Sorry, this file is too long to be edited manually. Please upload it directly.'
-            )
-          } else if (/too many comments or tracked changes/.test(message)) {
-            this.ide.showGenericMessageModal(
-              'Too many comments or tracked changes',
-              'Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments.'
-            )
-          } else {
-            this.ide.socket.disconnect()
-            this.ide.reportError(error, meta)
-            this.ide.showOutOfSyncModal(
-              'Out of sync',
-              "Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>",
-              sharejs_doc.doc._doc.snapshot
-            )
-          }
-          const removeHandler = this.$scope.$on('project:joined', () => {
-            this.openDoc(doc, { forceReopen: true })
-            removeHandler()
-          })
-        })
+    _unbindFromDocumentEvents(document) {
+      return document.off()
+    }
 
-        return sharejs_doc.on('externalUpdate', update => {
-          if (this._ignoreExternalUpdates) {
-            return
-          }
-          if (
-            _.property(['meta', 'type'])(update) === 'external' &&
-            _.property(['meta', 'source'])(update) === 'git-bridge'
-          ) {
-            return
-          }
-          return this.ide.showGenericMessageModal(
-            'Document Updated Externally',
-            'This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history.'
-          )
-        })
-      }
+    getCurrentDocValue() {
+      return this.$scope.editor.sharejs_doc != null
+        ? this.$scope.editor.sharejs_doc.getSnapshot()
+        : undefined
+    }
 
-      _unbindFromDocumentEvents(document) {
-        return document.off()
-      }
+    getCurrentDocId() {
+      return this.$scope.editor.open_doc_id
+    }
 
-      getCurrentDocValue() {
-        return this.$scope.editor.sharejs_doc != null
-          ? this.$scope.editor.sharejs_doc.getSnapshot()
-          : undefined
-      }
+    startIgnoringExternalUpdates() {
+      return (this._ignoreExternalUpdates = true)
+    }
 
-      getCurrentDocId() {
-        return this.$scope.editor.open_doc_id
+    stopIgnoringExternalUpdates() {
+      return (this._ignoreExternalUpdates = false)
+    }
+    _syncTrackChangesState(doc) {
+      let tryToggle
+      if (doc == null) {
+        return
       }
 
-      startIgnoringExternalUpdates() {
-        return (this._ignoreExternalUpdates = true)
+      if (this._syncTimeout != null) {
+        clearTimeout(this._syncTimeout)
+        this._syncTimeout = null
       }
 
-      stopIgnoringExternalUpdates() {
-        return (this._ignoreExternalUpdates = false)
+      const want = this.$scope.editor.wantTrackChanges
+      const have = doc.getTrackingChanges()
+      if (want === have) {
+        this.$scope.editor.trackChanges = want
+        return
       }
-      _syncTrackChangesState(doc) {
-        let tryToggle
-        if (doc == null) {
-          return
-        }
-
-        if (this._syncTimeout != null) {
-          clearTimeout(this._syncTimeout)
-          this._syncTimeout = null
-        }
 
-        const want = this.$scope.editor.wantTrackChanges
-        const have = doc.getTrackingChanges()
-        if (want === have) {
-          this.$scope.editor.trackChanges = want
-          return
+      return (tryToggle = () => {
+        const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
+        if (saved) {
+          doc.setTrackingChanges(want)
+          return this.$scope.$apply(() => {
+            return (this.$scope.editor.trackChanges = want)
+          })
+        } else {
+          return (this._syncTimeout = setTimeout(tryToggle, 100))
         }
-
-        return (tryToggle = () => {
-          const saved =
-            doc.getInflightOp() == null && doc.getPendingOp() == null
-          if (saved) {
-            doc.setTrackingChanges(want)
-            return this.$scope.$apply(() => {
-              return (this.$scope.editor.trackChanges = want)
-            })
-          } else {
-            return (this._syncTimeout = setTimeout(tryToggle, 100))
-          }
-        })()
-      }
+      })()
     }
-    EditorManager.initClass()
-    return EditorManager
-  })())
-})
+  }
+  EditorManager.initClass()
+  return EditorManager
+})())

+ 34 - 35
services/web/frontend/js/ide/editor/EditorShareJsCodec.js

@@ -9,43 +9,42 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  let EditorShareJsCodec
-  return (EditorShareJsCodec = {
-    rangeToShareJs(range, lines) {
-      let offset = 0
-      for (let i = 0; i < lines.length; i++) {
-        const line = lines[i]
-        offset += i < range.row ? line.length : range.column
-      }
-      offset += range.row // Include newlines
-      return offset
-    },
+let EditorShareJsCodec
 
-    changeToShareJs(delta, lines) {
-      const offset = EditorShareJsCodec.rangeToShareJs(delta.start, lines)
+export default (EditorShareJsCodec = {
+  rangeToShareJs(range, lines) {
+    let offset = 0
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i]
+      offset += i < range.row ? line.length : range.column
+    }
+    offset += range.row // Include newlines
+    return offset
+  },
 
-      const text = delta.lines.join('\n')
-      switch (delta.action) {
-        case 'insert':
-          return { i: text, p: offset }
-        case 'remove':
-          return { d: text, p: offset }
-        default:
-          throw new Error(`unknown action: ${delta.action}`)
-      }
-    },
+  changeToShareJs(delta, lines) {
+    const offset = EditorShareJsCodec.rangeToShareJs(delta.start, lines)
 
-    shareJsOffsetToRowColumn(offset, lines) {
-      let row = 0
-      for (row = 0; row < lines.length; row++) {
-        const line = lines[row]
-        if (offset <= line.length) {
-          break
-        }
-        offset -= lines[row].length + 1
-      } // + 1 for newline char
-      return { row, column: offset }
+    const text = delta.lines.join('\n')
+    switch (delta.action) {
+      case 'insert':
+        return { i: text, p: offset }
+      case 'remove':
+        return { d: text, p: offset }
+      default:
+        throw new Error(`unknown action: ${delta.action}`)
     }
-  })
+  },
+
+  shareJsOffsetToRowColumn(offset, lines) {
+    let row = 0
+    for (row = 0; row < lines.length; row++) {
+      const line = lines[row]
+      if (offset <= line.length) {
+        break
+      }
+      offset -= lines[row].length + 1
+    } // + 1 for newline char
+    return { row, column: offset }
+  }
 })

+ 340 - 345
services/web/frontend/js/ide/editor/ShareJsDoc.js

@@ -16,398 +16,393 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../utils/EventEmitter', 'libs/sharejs'], function(
-  EventEmitter,
-  ShareJs
-) {
-  let ShareJsDoc
-  const SINGLE_USER_FLUSH_DELAY = 1000 // ms
+import EventEmitter from '../../utils/EventEmitter'
+import ShareJs from 'libs/sharejs'
+let ShareJsDoc
+const SINGLE_USER_FLUSH_DELAY = 1000 // ms
 
-  return (ShareJsDoc = (function() {
-    ShareJsDoc = class ShareJsDoc extends EventEmitter {
-      static initClass() {
-        this.prototype.INFLIGHT_OP_TIMEOUT = 5000 // Retry sending ops after 5 seconds without an ack
-        this.prototype.WAIT_FOR_CONNECTION_TIMEOUT = 500
+export default (ShareJsDoc = (function() {
+  ShareJsDoc = class ShareJsDoc extends EventEmitter {
+    static initClass() {
+      this.prototype.INFLIGHT_OP_TIMEOUT = 5000 // Retry sending ops after 5 seconds without an ack
+      this.prototype.WAIT_FOR_CONNECTION_TIMEOUT = 500
 
-        this.prototype.FATAL_OP_TIMEOUT = 30000
-      }
-      constructor(doc_id, docLines, version, socket) {
-        super()
-        // Dencode any binary bits of data
-        // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
-        this.doc_id = doc_id
-        this.socket = socket
-        this.type = 'text'
-        docLines = Array.from(docLines).map(line =>
-          decodeURIComponent(escape(line))
-        )
-        const snapshot = docLines.join('\n')
-        this.track_changes = false
+      this.prototype.FATAL_OP_TIMEOUT = 30000
+    }
+    constructor(doc_id, docLines, version, socket) {
+      super()
+      // Dencode any binary bits of data
+      // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
+      this.doc_id = doc_id
+      this.socket = socket
+      this.type = 'text'
+      docLines = Array.from(docLines).map(line =>
+        decodeURIComponent(escape(line))
+      )
+      const snapshot = docLines.join('\n')
+      this.track_changes = false
 
-        this.connection = {
-          send: update => {
-            this._startInflightOpTimeout(update)
-            if (
-              window.disconnectOnUpdate != null &&
-              Math.random() < window.disconnectOnUpdate
-            ) {
-              sl_console.log('Disconnecting on update', update)
-              window._ide.socket.socket.disconnect()
-            }
-            if (
-              window.dropUpdates != null &&
-              Math.random() < window.dropUpdates
-            ) {
-              sl_console.log('Simulating a lost update', update)
-              return
+      this.connection = {
+        send: update => {
+          this._startInflightOpTimeout(update)
+          if (
+            window.disconnectOnUpdate != null &&
+            Math.random() < window.disconnectOnUpdate
+          ) {
+            sl_console.log('Disconnecting on update', update)
+            window._ide.socket.socket.disconnect()
+          }
+          if (
+            window.dropUpdates != null &&
+            Math.random() < window.dropUpdates
+          ) {
+            sl_console.log('Simulating a lost update', update)
+            return
+          }
+          if (this.track_changes) {
+            if (update.meta == null) {
+              update.meta = {}
             }
-            if (this.track_changes) {
-              if (update.meta == null) {
-                update.meta = {}
+            update.meta.tc = this.track_changes_id_seeds.inflight
+          }
+          return this.socket.emit(
+            'applyOtUpdate',
+            this.doc_id,
+            update,
+            error => {
+              if (error != null) {
+                return this._handleError(error)
               }
-              update.meta.tc = this.track_changes_id_seeds.inflight
             }
-            return this.socket.emit(
-              'applyOtUpdate',
-              this.doc_id,
-              update,
-              error => {
-                if (error != null) {
-                  return this._handleError(error)
-                }
-              }
-            )
-          },
-          state: 'ok',
-          id: this.socket.socket.sessionid
-        }
+          )
+        },
+        state: 'ok',
+        id: this.socket.socket.sessionid
+      }
 
-        this._doc = new ShareJs.Doc(this.connection, this.doc_id, {
-          type: this.type
-        })
-        this._doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY)
-        this._doc.on('change', (...args) => {
-          return this.trigger('change', ...Array.from(args))
-        })
-        this._doc.on('acknowledge', () => {
-          this.lastAcked = new Date() // note time of last ack from server for an op we sent
-          return this.trigger('acknowledge')
-        })
-        this._doc.on('remoteop', (...args) => {
-          // As soon as we're working with a collaborator, start sending
-          // ops as quickly as possible for low latency.
-          this._doc.setFlushDelay(0)
-          return this.trigger('remoteop', ...Array.from(args))
-        })
-        this._doc.on('flipped_pending_to_inflight', () => {
-          return this.trigger('flipped_pending_to_inflight')
-        })
-        this._doc.on('saved', () => {
-          return this.trigger('saved')
-        })
-        this._doc.on('error', e => {
-          return this._handleError(e)
-        })
+      this._doc = new ShareJs.Doc(this.connection, this.doc_id, {
+        type: this.type
+      })
+      this._doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY)
+      this._doc.on('change', (...args) => {
+        return this.trigger('change', ...Array.from(args))
+      })
+      this._doc.on('acknowledge', () => {
+        this.lastAcked = new Date() // note time of last ack from server for an op we sent
+        return this.trigger('acknowledge')
+      })
+      this._doc.on('remoteop', (...args) => {
+        // As soon as we're working with a collaborator, start sending
+        // ops as quickly as possible for low latency.
+        this._doc.setFlushDelay(0)
+        return this.trigger('remoteop', ...Array.from(args))
+      })
+      this._doc.on('flipped_pending_to_inflight', () => {
+        return this.trigger('flipped_pending_to_inflight')
+      })
+      this._doc.on('saved', () => {
+        return this.trigger('saved')
+      })
+      this._doc.on('error', e => {
+        return this._handleError(e)
+      })
 
-        this._bindToDocChanges(this._doc)
+      this._bindToDocChanges(this._doc)
 
-        this.processUpdateFromServer({
-          open: true,
-          v: version,
-          snapshot
-        })
-      }
+      this.processUpdateFromServer({
+        open: true,
+        v: version,
+        snapshot
+      })
+    }
 
-      submitOp(...args) {
-        return this._doc.submitOp(...Array.from(args || []))
-      }
+    submitOp(...args) {
+      return this._doc.submitOp(...Array.from(args || []))
+    }
 
-      // The following code puts out of order messages into a queue
-      // so that they can be processed in order.  This is a workaround
-      // for messages being delayed by redis cluster.
-      // FIXME: REMOVE THIS WHEN REDIS PUBSUB IS SENDING MESSAGES IN ORDER
-      _isAheadOfExpectedVersion(message) {
-        return this._doc.version > 0 && message.v > this._doc.version
-      }
+    // The following code puts out of order messages into a queue
+    // so that they can be processed in order.  This is a workaround
+    // for messages being delayed by redis cluster.
+    // FIXME: REMOVE THIS WHEN REDIS PUBSUB IS SENDING MESSAGES IN ORDER
+    _isAheadOfExpectedVersion(message) {
+      return this._doc.version > 0 && message.v > this._doc.version
+    }
 
-      _pushOntoQueue(message) {
-        sl_console.log(`[processUpdate] push onto queue ${message.v}`)
-        // set a timer so that we never leave messages in the queue indefinitely
-        if (!this.queuedMessageTimer) {
-          this.queuedMessageTimer = setTimeout(() => {
-            sl_console.log(
-              `[processUpdate] queue timeout fired for ${message.v}`
-            )
-            // force the message to be processed after the timeout,
-            // it will cause an error if the missing update has not arrived
-            this.processUpdateFromServer(message)
-          }, this.INFLIGHT_OP_TIMEOUT)
-        }
-        this.queuedMessages.push(message)
-        // keep the queue in order, lowest version first
-        this.queuedMessages.sort(function(a, b) {
-          return a.v - b.v
-        })
+    _pushOntoQueue(message) {
+      sl_console.log(`[processUpdate] push onto queue ${message.v}`)
+      // set a timer so that we never leave messages in the queue indefinitely
+      if (!this.queuedMessageTimer) {
+        this.queuedMessageTimer = setTimeout(() => {
+          sl_console.log(`[processUpdate] queue timeout fired for ${message.v}`)
+          // force the message to be processed after the timeout,
+          // it will cause an error if the missing update has not arrived
+          this.processUpdateFromServer(message)
+        }, this.INFLIGHT_OP_TIMEOUT)
       }
+      this.queuedMessages.push(message)
+      // keep the queue in order, lowest version first
+      this.queuedMessages.sort(function(a, b) {
+        return a.v - b.v
+      })
+    }
 
-      _clearQueue() {
-        this.queuedMessages = []
-      }
+    _clearQueue() {
+      this.queuedMessages = []
+    }
 
-      _processQueue() {
-        if (this.queuedMessages.length > 0) {
-          const nextAvailableVersion = this.queuedMessages[0].v
-          if (nextAvailableVersion > this._doc.version) {
-            // there are updates we still can't apply yet
-          } else {
-            // there's a version we can accept on the queue, apply it
-            sl_console.log(
-              `[processUpdate] taken from queue ${nextAvailableVersion}`
-            )
-            this.processUpdateFromServerInOrder(this.queuedMessages.shift())
-            // clear the pending timer if the queue has now been cleared
-            if (this.queuedMessages.length === 0 && this.queuedMessageTimer) {
-              sl_console.log('[processUpdate] queue is empty, cleared timeout')
-              clearTimeout(this.queuedMessageTimer)
-              this.queuedMessageTimer = null
-            }
+    _processQueue() {
+      if (this.queuedMessages.length > 0) {
+        const nextAvailableVersion = this.queuedMessages[0].v
+        if (nextAvailableVersion > this._doc.version) {
+          // there are updates we still can't apply yet
+        } else {
+          // there's a version we can accept on the queue, apply it
+          sl_console.log(
+            `[processUpdate] taken from queue ${nextAvailableVersion}`
+          )
+          this.processUpdateFromServerInOrder(this.queuedMessages.shift())
+          // clear the pending timer if the queue has now been cleared
+          if (this.queuedMessages.length === 0 && this.queuedMessageTimer) {
+            sl_console.log('[processUpdate] queue is empty, cleared timeout')
+            clearTimeout(this.queuedMessageTimer)
+            this.queuedMessageTimer = null
           }
         }
       }
+    }
 
-      // FIXME: This is the new method which reorders incoming updates if needed
-      // called from Document.js
-      processUpdateFromServerInOrder(message) {
-        // Create an array to hold queued messages
-        if (!this.queuedMessages) {
-          this.queuedMessages = []
-        }
-        // Is this update ahead of the next expected update?
-        // If so, put it on a queue to be handled later.
-        if (this._isAheadOfExpectedVersion(message)) {
-          this._pushOntoQueue(message)
-          return // defer processing this update for now
-        }
-        var error = this.processUpdateFromServer(message)
-        if (
-          error instanceof Error &&
-          error.message === 'Invalid version from server'
-        ) {
-          // if there was an error, abandon the queued updates ahead of this one
-          this._clearQueue()
-          return
-        }
-        // Do we have any messages queued up?
-        // find the next message if available
-        this._processQueue()
+    // FIXME: This is the new method which reorders incoming updates if needed
+    // called from Document.js
+    processUpdateFromServerInOrder(message) {
+      // Create an array to hold queued messages
+      if (!this.queuedMessages) {
+        this.queuedMessages = []
       }
-
-      // FIXME: This is the original method. Switch back to this when redis
-      // issues are resolved.
-      processUpdateFromServer(message) {
-        try {
-          this._doc._onMessage(message)
-        } catch (error) {
-          // Version mismatches are thrown as errors
-          console.log(error)
-          this._handleError(error)
-          return error // return the error for queue handling
-        }
-
-        if (
-          __guard__(message != null ? message.meta : undefined, x => x.type) ===
-          'external'
-        ) {
-          return this.trigger('externalUpdate', message)
-        }
+      // Is this update ahead of the next expected update?
+      // If so, put it on a queue to be handled later.
+      if (this._isAheadOfExpectedVersion(message)) {
+        this._pushOntoQueue(message)
+        return // defer processing this update for now
       }
-
-      catchUp(updates) {
-        return (() => {
-          const result = []
-          for (let i = 0; i < updates.length; i++) {
-            const update = updates[i]
-            update.v = this._doc.version
-            update.doc = this.doc_id
-            result.push(this.processUpdateFromServer(update))
-          }
-          return result
-        })()
+      var error = this.processUpdateFromServer(message)
+      if (
+        error instanceof Error &&
+        error.message === 'Invalid version from server'
+      ) {
+        // if there was an error, abandon the queued updates ahead of this one
+        this._clearQueue()
+        return
       }
+      // Do we have any messages queued up?
+      // find the next message if available
+      this._processQueue()
+    }
 
-      getSnapshot() {
-        return this._doc.snapshot
-      }
-      getVersion() {
-        return this._doc.version
-      }
-      getType() {
-        return this.type
+    // FIXME: This is the original method. Switch back to this when redis
+    // issues are resolved.
+    processUpdateFromServer(message) {
+      try {
+        this._doc._onMessage(message)
+      } catch (error) {
+        // Version mismatches are thrown as errors
+        console.log(error)
+        this._handleError(error)
+        return error // return the error for queue handling
       }
 
-      clearInflightAndPendingOps() {
-        this._doc.inflightOp = null
-        this._doc.inflightCallbacks = []
-        this._doc.pendingOp = null
-        return (this._doc.pendingCallbacks = [])
+      if (
+        __guard__(message != null ? message.meta : undefined, x => x.type) ===
+        'external'
+      ) {
+        return this.trigger('externalUpdate', message)
       }
+    }
 
-      flushPendingOps() {
-        // This will flush any ops that are pending.
-        // If there is an inflight op it will do nothing.
-        return this._doc.flush()
-      }
+    catchUp(updates) {
+      return (() => {
+        const result = []
+        for (let i = 0; i < updates.length; i++) {
+          const update = updates[i]
+          update.v = this._doc.version
+          update.doc = this.doc_id
+          result.push(this.processUpdateFromServer(update))
+        }
+        return result
+      })()
+    }
 
-      updateConnectionState(state) {
-        sl_console.log(`[updateConnectionState] Setting state to ${state}`)
-        this.connection.state = state
-        this.connection.id = this.socket.socket.sessionid
-        this._doc.autoOpen = false
-        this._doc._connectionStateChanged(state)
-        return (this.lastAcked = null) // reset the last ack time when connection changes
-      }
+    getSnapshot() {
+      return this._doc.snapshot
+    }
+    getVersion() {
+      return this._doc.version
+    }
+    getType() {
+      return this.type
+    }
 
-      hasBufferedOps() {
-        return this._doc.inflightOp != null || this._doc.pendingOp != null
-      }
+    clearInflightAndPendingOps() {
+      this._doc.inflightOp = null
+      this._doc.inflightCallbacks = []
+      this._doc.pendingOp = null
+      return (this._doc.pendingCallbacks = [])
+    }
 
-      getInflightOp() {
-        return this._doc.inflightOp
-      }
-      getPendingOp() {
-        return this._doc.pendingOp
-      }
-      getRecentAck() {
-        // check if we have received an ack recently (within a factor of two of the single user flush delay)
-        return (
-          this.lastAcked != null &&
-          new Date() - this.lastAcked < 2 * SINGLE_USER_FLUSH_DELAY
-        )
-      }
-      getOpSize(op) {
-        // compute size of an op from its components
-        // (total number of characters inserted and deleted)
-        let size = 0
-        for (let component of Array.from(op || [])) {
-          if ((component != null ? component.i : undefined) != null) {
-            size += component.i.length
-          }
-          if ((component != null ? component.d : undefined) != null) {
-            size += component.d.length
-          }
+    flushPendingOps() {
+      // This will flush any ops that are pending.
+      // If there is an inflight op it will do nothing.
+      return this._doc.flush()
+    }
+
+    updateConnectionState(state) {
+      sl_console.log(`[updateConnectionState] Setting state to ${state}`)
+      this.connection.state = state
+      this.connection.id = this.socket.socket.sessionid
+      this._doc.autoOpen = false
+      this._doc._connectionStateChanged(state)
+      return (this.lastAcked = null) // reset the last ack time when connection changes
+    }
+
+    hasBufferedOps() {
+      return this._doc.inflightOp != null || this._doc.pendingOp != null
+    }
+
+    getInflightOp() {
+      return this._doc.inflightOp
+    }
+    getPendingOp() {
+      return this._doc.pendingOp
+    }
+    getRecentAck() {
+      // check if we have received an ack recently (within a factor of two of the single user flush delay)
+      return (
+        this.lastAcked != null &&
+        new Date() - this.lastAcked < 2 * SINGLE_USER_FLUSH_DELAY
+      )
+    }
+    getOpSize(op) {
+      // compute size of an op from its components
+      // (total number of characters inserted and deleted)
+      let size = 0
+      for (let component of Array.from(op || [])) {
+        if ((component != null ? component.i : undefined) != null) {
+          size += component.i.length
+        }
+        if ((component != null ? component.d : undefined) != null) {
+          size += component.d.length
         }
-        return size
       }
+      return size
+    }
 
-      attachToAce(ace) {
-        return this._doc.attach_ace(ace, false, window.maxDocLength)
-      }
-      detachFromAce() {
-        return typeof this._doc.detach_ace === 'function'
-          ? this._doc.detach_ace()
-          : undefined
-      }
+    attachToAce(ace) {
+      return this._doc.attach_ace(ace, false, window.maxDocLength)
+    }
+    detachFromAce() {
+      return typeof this._doc.detach_ace === 'function'
+        ? this._doc.detach_ace()
+        : undefined
+    }
 
-      attachToCM(cm) {
-        return this._doc.attach_cm(cm, false)
-      }
-      detachFromCM() {
-        return typeof this._doc.detach_cm === 'function'
-          ? this._doc.detach_cm()
-          : undefined
-      } // If we're waiting for the project to join, try again in 0.5 seconds
-      _startInflightOpTimeout(update) {
-        this._startFatalTimeoutTimer(update)
-        var retryOp = () => {
-          // Only send the update again if inflightOp is still populated
-          // This can be cleared when hard reloading the document in which
-          // case we don't want to keep trying to send it.
-          sl_console.log('[inflightOpTimeout] Trying op again')
-          if (this._doc.inflightOp != null) {
-            // When there is a socket.io disconnect, @_doc.inflightSubmittedIds
-            // is updated with the socket.io client id of the current op in flight
-            // (meta.source of the op).
-            // @connection.id is the client id of the current socket.io session.
-            // So we need both depending on whether the op was submitted before
-            // one or more disconnects, or if it was submitted during the current session.
-            update.dupIfSource = [
-              this.connection.id,
-              ...Array.from(this._doc.inflightSubmittedIds)
-            ]
+    attachToCM(cm) {
+      return this._doc.attach_cm(cm, false)
+    }
+    detachFromCM() {
+      return typeof this._doc.detach_cm === 'function'
+        ? this._doc.detach_cm()
+        : undefined
+    } // If we're waiting for the project to join, try again in 0.5 seconds
+    _startInflightOpTimeout(update) {
+      this._startFatalTimeoutTimer(update)
+      var retryOp = () => {
+        // Only send the update again if inflightOp is still populated
+        // This can be cleared when hard reloading the document in which
+        // case we don't want to keep trying to send it.
+        sl_console.log('[inflightOpTimeout] Trying op again')
+        if (this._doc.inflightOp != null) {
+          // When there is a socket.io disconnect, @_doc.inflightSubmittedIds
+          // is updated with the socket.io client id of the current op in flight
+          // (meta.source of the op).
+          // @connection.id is the client id of the current socket.io session.
+          // So we need both depending on whether the op was submitted before
+          // one or more disconnects, or if it was submitted during the current session.
+          update.dupIfSource = [
+            this.connection.id,
+            ...Array.from(this._doc.inflightSubmittedIds)
+          ]
 
-            // We must be joined to a project for applyOtUpdate to work on the real-time
-            // service, so don't send an op if we're not. Connection state is set to 'ok'
-            // when we've joined the project
-            if (this.connection.state !== 'ok') {
-              let timer
-              sl_console.log(
-                '[inflightOpTimeout] Not connected, retrying in 0.5s'
-              )
-              return (timer = setTimeout(
-                retryOp,
-                this.WAIT_FOR_CONNECTION_TIMEOUT
-              ))
-            } else {
-              sl_console.log('[inflightOpTimeout] Sending')
-              return this.connection.send(update)
-            }
+          // We must be joined to a project for applyOtUpdate to work on the real-time
+          // service, so don't send an op if we're not. Connection state is set to 'ok'
+          // when we've joined the project
+          if (this.connection.state !== 'ok') {
+            let timer
+            sl_console.log(
+              '[inflightOpTimeout] Not connected, retrying in 0.5s'
+            )
+            return (timer = setTimeout(
+              retryOp,
+              this.WAIT_FOR_CONNECTION_TIMEOUT
+            ))
+          } else {
+            sl_console.log('[inflightOpTimeout] Sending')
+            return this.connection.send(update)
           }
         }
-
-        const timer = setTimeout(retryOp, this.INFLIGHT_OP_TIMEOUT)
-        return this._doc.inflightCallbacks.push(() => {
-          this._clearFatalTimeoutTimer()
-          return clearTimeout(timer)
-        }) // 30 seconds
-      }
-      _startFatalTimeoutTimer(update) {
-        // If an op doesn't get acked within FATAL_OP_TIMEOUT, something has
-        // gone unrecoverably wrong (the op will have been retried multiple times)
-        if (this._timeoutTimer != null) {
-          return
-        }
-        return (this._timeoutTimer = setTimeout(() => {
-          this._clearFatalTimeoutTimer()
-          return this.trigger('op:timeout', update)
-        }, this.FATAL_OP_TIMEOUT))
       }
 
-      _clearFatalTimeoutTimer() {
-        if (this._timeoutTimer == null) {
-          return
-        }
-        clearTimeout(this._timeoutTimer)
-        return (this._timeoutTimer = null)
+      const timer = setTimeout(retryOp, this.INFLIGHT_OP_TIMEOUT)
+      return this._doc.inflightCallbacks.push(() => {
+        this._clearFatalTimeoutTimer()
+        return clearTimeout(timer)
+      }) // 30 seconds
+    }
+    _startFatalTimeoutTimer(update) {
+      // If an op doesn't get acked within FATAL_OP_TIMEOUT, something has
+      // gone unrecoverably wrong (the op will have been retried multiple times)
+      if (this._timeoutTimer != null) {
+        return
       }
+      return (this._timeoutTimer = setTimeout(() => {
+        this._clearFatalTimeoutTimer()
+        return this.trigger('op:timeout', update)
+      }, this.FATAL_OP_TIMEOUT))
+    }
 
-      _handleError(error, meta) {
-        if (meta == null) {
-          meta = {}
-        }
-        return this.trigger('error', error, meta)
+    _clearFatalTimeoutTimer() {
+      if (this._timeoutTimer == null) {
+        return
       }
+      clearTimeout(this._timeoutTimer)
+      return (this._timeoutTimer = null)
+    }
 
-      _bindToDocChanges(doc) {
-        const { submitOp } = doc
-        doc.submitOp = (...args) => {
-          this.trigger('op:sent', ...Array.from(args))
-          doc.pendingCallbacks.push(() => {
-            return this.trigger('op:acknowledged', ...Array.from(args))
-          })
-          return submitOp.apply(doc, args)
-        }
+    _handleError(error, meta) {
+      if (meta == null) {
+        meta = {}
+      }
+      return this.trigger('error', error, meta)
+    }
 
-        const { flush } = doc
-        return (doc.flush = (...args) => {
-          this.trigger('flush', doc.inflightOp, doc.pendingOp, doc.version)
-          return flush.apply(doc, args)
+    _bindToDocChanges(doc) {
+      const { submitOp } = doc
+      doc.submitOp = (...args) => {
+        this.trigger('op:sent', ...Array.from(args))
+        doc.pendingCallbacks.push(() => {
+          return this.trigger('op:acknowledged', ...Array.from(args))
         })
+        return submitOp.apply(doc, args)
       }
+
+      const { flush } = doc
+      return (doc.flush = (...args) => {
+        this.trigger('flush', doc.inflightOp, doc.pendingOp, doc.version)
+        return flush.apply(doc, args)
+      })
     }
-    ShareJsDoc.initClass()
-    return ShareJsDoc
-  })())
-})
+  }
+  ShareJsDoc.initClass()
+  return ShareJsDoc
+})())
 
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null

+ 32 - 31
services/web/frontend/js/ide/editor/components/spellMenu.js

@@ -8,38 +8,39 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.component('spellMenu', {
-    bindings: {
-      open: '<',
-      top: '<',
-      left: '<',
-      layoutFromBottom: '<',
-      highlight: '<',
-      replaceWord: '&',
-      learnWord: '&'
-    },
-    template: `\
+import App from '../../../base'
+
+export default App.component('spellMenu', {
+  bindings: {
+    open: '<',
+    top: '<',
+    left: '<',
+    layoutFromBottom: '<',
+    highlight: '<',
+    replaceWord: '&',
+    learnWord: '&'
+  },
+  template: `\
 <div
-  class="dropdown context-menu spell-check-menu"
-  ng-show="$ctrl.open"
-  ng-style="{top: $ctrl.top, left: $ctrl.left}"
-  ng-class="{open: $ctrl.open, 'spell-check-menu-from-bottom': $ctrl.layoutFromBottom}"
+class="dropdown context-menu spell-check-menu"
+ng-show="$ctrl.open"
+ng-style="{top: $ctrl.top, left: $ctrl.left}"
+ng-class="{open: $ctrl.open, 'spell-check-menu-from-bottom': $ctrl.layoutFromBottom}"
 >
-  <ul class="dropdown-menu">
-    <li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
-      <a
-        href
-        ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
-      >
-        {{ suggestion }}
-      </a>
-    </li>
-    <li class="divider"></li>
-    <li>
-      <a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
-    </li>
-  </ul>
+<ul class="dropdown-menu">
+  <li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
+    <a
+      href
+      ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
+    >
+      {{ suggestion }}
+    </a>
+  </li>
+  <li class="divider"></li>
+  <li>
+    <a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
+  </li>
+</ul>
 </div>\
 `
-  }))
+})

+ 75 - 73
services/web/frontend/js/ide/editor/controllers/SavingNotificationController.js

@@ -11,91 +11,93 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base', '../Document'], (App, Document) =>
-  App.controller('SavingNotificationController', function(
-    $scope,
-    $interval,
-    ide
-  ) {
-    let warnAboutUnsavedChanges
-    setInterval(() => pollSavedStatus(), 1000)
+import App from '../../../base'
+import Document from '../Document'
 
-    $(window).bind('beforeunload', () => {
-      return warnAboutUnsavedChanges()
-    })
+export default App.controller('SavingNotificationController', function(
+  $scope,
+  $interval,
+  ide
+) {
+  let warnAboutUnsavedChanges
+  setInterval(() => pollSavedStatus(), 1000)
 
-    let lockEditorModal = null // modal showing "connection lost"
-    let originalPermissionsLevel
-    const MAX_UNSAVED_SECONDS = 15 // lock the editor after this time if unsaved
+  $(window).bind('beforeunload', () => {
+    return warnAboutUnsavedChanges()
+  })
 
-    $scope.docSavingStatus = {}
-    var pollSavedStatus = function() {
-      let t
-      const oldStatus = $scope.docSavingStatus
-      const oldUnsavedCount = $scope.docSavingStatusCount
-      const newStatus = {}
-      let newUnsavedCount = 0
-      let maxUnsavedSeconds = 0
+  let lockEditorModal = null // modal showing "connection lost"
+  let originalPermissionsLevel
+  const MAX_UNSAVED_SECONDS = 15 // lock the editor after this time if unsaved
 
-      for (let doc_id in Document.openDocs) {
-        const doc = Document.openDocs[doc_id]
-        const saving = doc.pollSavedStatus()
-        if (!saving) {
-          newUnsavedCount++
-          if (oldStatus[doc_id] != null) {
-            newStatus[doc_id] = oldStatus[doc_id]
-            t = newStatus[doc_id].unsavedSeconds += 1
-            if (t > maxUnsavedSeconds) {
-              maxUnsavedSeconds = t
-            }
-          } else {
-            newStatus[doc_id] = {
-              unsavedSeconds: 0,
-              doc: ide.fileTreeManager.findEntityById(doc_id)
-            }
+  $scope.docSavingStatus = {}
+  var pollSavedStatus = function() {
+    let t
+    const oldStatus = $scope.docSavingStatus
+    const oldUnsavedCount = $scope.docSavingStatusCount
+    const newStatus = {}
+    let newUnsavedCount = 0
+    let maxUnsavedSeconds = 0
+
+    for (let doc_id in Document.openDocs) {
+      const doc = Document.openDocs[doc_id]
+      const saving = doc.pollSavedStatus()
+      if (!saving) {
+        newUnsavedCount++
+        if (oldStatus[doc_id] != null) {
+          newStatus[doc_id] = oldStatus[doc_id]
+          t = newStatus[doc_id].unsavedSeconds += 1
+          if (t > maxUnsavedSeconds) {
+            maxUnsavedSeconds = t
+          }
+        } else {
+          newStatus[doc_id] = {
+            unsavedSeconds: 0,
+            doc: ide.fileTreeManager.findEntityById(doc_id)
           }
         }
       }
+    }
 
-      if (newUnsavedCount > 0 && t > MAX_UNSAVED_SECONDS && !lockEditorModal) {
-        lockEditorModal = ide.showLockEditorMessageModal(
-          'Connection lost',
-          'Sorry, the connection to the server is down.'
-        )
+    if (newUnsavedCount > 0 && t > MAX_UNSAVED_SECONDS && !lockEditorModal) {
+      lockEditorModal = ide.showLockEditorMessageModal(
+        'Connection lost',
+        'Sorry, the connection to the server is down.'
+      )
 
-        // put editor in readOnly mode
-        originalPermissionsLevel = ide.$scope.permissionsLevel
-        ide.$scope.permissionsLevel = 'readOnly'
+      // put editor in readOnly mode
+      originalPermissionsLevel = ide.$scope.permissionsLevel
+      ide.$scope.permissionsLevel = 'readOnly'
 
-        lockEditorModal.result.finally(() => {
-          lockEditorModal = null // unset the modal if connection comes back
-          // restore original permissions
-          ide.$scope.permissionsLevel = originalPermissionsLevel
-        })
-      }
+      lockEditorModal.result.finally(() => {
+        lockEditorModal = null // unset the modal if connection comes back
+        // restore original permissions
+        ide.$scope.permissionsLevel = originalPermissionsLevel
+      })
+    }
 
-      if (lockEditorModal && newUnsavedCount === 0) {
-        lockEditorModal.dismiss('connection back up')
-        // restore original permissions if they were changed
-        if (originalPermissionsLevel) {
-          ide.$scope.permissionsLevel = originalPermissionsLevel
-        }
+    if (lockEditorModal && newUnsavedCount === 0) {
+      lockEditorModal.dismiss('connection back up')
+      // restore original permissions if they were changed
+      if (originalPermissionsLevel) {
+        ide.$scope.permissionsLevel = originalPermissionsLevel
       }
+    }
 
-      // for performance, only update the display if the old or new
-      // counts of unsaved files are nonzeror.  If both old and new
-      // unsaved counts are zero then we know we are in a good state
-      // and don't need to do anything to the UI.
-      if (newUnsavedCount || oldUnsavedCount) {
-        $scope.docSavingStatus = newStatus
-        $scope.docSavingStatusCount = newUnsavedCount
-        return $scope.$apply()
-      }
+    // for performance, only update the display if the old or new
+    // counts of unsaved files are nonzeror.  If both old and new
+    // unsaved counts are zero then we know we are in a good state
+    // and don't need to do anything to the UI.
+    if (newUnsavedCount || oldUnsavedCount) {
+      $scope.docSavingStatus = newStatus
+      $scope.docSavingStatusCount = newUnsavedCount
+      return $scope.$apply()
     }
+  }
 
-    return (warnAboutUnsavedChanges = function() {
-      if (Document.hasUnsavedChanges()) {
-        return 'You have unsaved changes. If you leave now they will not be saved.'
-      }
-    })
-  }))
+  return (warnAboutUnsavedChanges = function() {
+    if (Document.hasUnsavedChanges()) {
+      return 'You have unsaved changes. If you leave now they will not be saved.'
+    }
+  })
+})

+ 863 - 887
services/web/frontend/js/ide/editor/directives/aceEditor.js

@@ -2,997 +2,973 @@
     camelcase,
     max-len
  */
-define([
-  '../../../base',
-  'ace/ace',
-  'ace/ext-searchbox',
-  'ace/ext-modelist',
-  'ace/keybinding-vim',
-  './aceEditor/undo/UndoManager',
-  './aceEditor/auto-complete/AutoCompleteManager',
-  './aceEditor/spell-check/SpellCheckManager',
-  './aceEditor/spell-check/SpellCheckAdapter',
-  './aceEditor/highlights/HighlightsManager',
-  './aceEditor/cursor-position/CursorPositionManager',
-  './aceEditor/cursor-position/CursorPositionAdapter',
-  './aceEditor/track-changes/TrackChangesManager',
-  './aceEditor/track-changes/TrackChangesAdapter',
-  './aceEditor/metadata/MetadataManager',
-  '../../metadata/services/metadata',
-  '../../graphics/services/graphics',
-  '../../preamble/services/preamble',
-  '../../files/services/files'
-], function(
-  App,
-  Ace,
-  _ignore1,
-  _ignore2,
-  _ignore3,
-  UndoManager,
-  AutoCompleteManager,
-  SpellCheckManager,
-  SpellCheckAdapter,
-  HighlightsManager,
-  CursorPositionManager,
-  CursorPositionAdapter,
-  TrackChangesManager,
-  TrackChangesAdapter,
-  MetadataManager
-) {
-  let syntaxValidationEnabled
-  const { EditSession } = ace.require('ace/edit_session')
-  const ModeList = ace.require('ace/ext/modelist')
-  const { Vim } = ace.require('ace/keyboard/vim')
-  const SearchBox = ace.require('ace/ext/searchbox')
-
-  // Set the base path that ace will fetch modes/snippets/workers from
-  if (window.aceBasePath !== '') {
-    syntaxValidationEnabled = true
-    ace.config.set('basePath', `${window.aceBasePath}`)
-  } else {
-    syntaxValidationEnabled = false
-  }
-
-  // By default, don't use workers - enable them per-session as required
-  ace.config.setDefaultValue('session', 'useWorker', false)
-
-  // Ace loads its script itself, so we need to hook in to be able to clear
-  // the cache.
-  if (ace.config._moduleUrl == null) {
-    ace.config._moduleUrl = ace.config.moduleUrl
-    ace.config.moduleUrl = function(...args) {
-      const url = ace.config._moduleUrl(...Array.from(args || []))
-      return url
-    }
+import App from '../../../base'
+import UndoManager from './aceEditor/undo/UndoManager'
+import AutoCompleteManager from './aceEditor/auto-complete/AutoCompleteManager'
+import SpellCheckManager from './aceEditor/spell-check/SpellCheckManager'
+import SpellCheckAdapter from './aceEditor/spell-check/SpellCheckAdapter'
+import HighlightsManager from './aceEditor/highlights/HighlightsManager'
+import CursorPositionManager from './aceEditor/cursor-position/CursorPositionManager'
+import CursorPositionAdapter from './aceEditor/cursor-position/CursorPositionAdapter'
+import TrackChangesManager from './aceEditor/track-changes/TrackChangesManager'
+import TrackChangesAdapter from './aceEditor/track-changes/TrackChangesAdapter'
+import MetadataManager from './aceEditor/metadata/MetadataManager'
+import 'ace/ace'
+import 'ace/ext-searchbox'
+import 'ace/ext-modelist'
+import 'ace/keybinding-vim'
+import '../../metadata/services/metadata'
+import '../../graphics/services/graphics'
+import '../../preamble/services/preamble'
+import '../../files/services/files'
+let syntaxValidationEnabled
+const { EditSession } = ace.require('ace/edit_session')
+const ModeList = ace.require('ace/ext/modelist')
+const { Vim } = ace.require('ace/keyboard/vim')
+const SearchBox = ace.require('ace/ext/searchbox')
+
+// Set the base path that ace will fetch modes/snippets/workers from
+if (window.aceBasePath !== '') {
+  syntaxValidationEnabled = true
+  ace.config.set('basePath', `${window.aceBasePath}`)
+} else {
+  syntaxValidationEnabled = false
+}
+
+// By default, don't use workers - enable them per-session as required
+ace.config.setDefaultValue('session', 'useWorker', false)
+
+// Ace loads its script itself, so we need to hook in to be able to clear
+// the cache.
+if (ace.config._moduleUrl == null) {
+  ace.config._moduleUrl = ace.config.moduleUrl
+  ace.config.moduleUrl = function(...args) {
+    const url = ace.config._moduleUrl(...Array.from(args || []))
+    return url
   }
-
-  App.directive('aceEditor', function(
-    $timeout,
-    $compile,
-    $rootScope,
-    eventTracking,
-    localStorage,
-    $cacheFactory,
-    metadata,
-    graphics,
-    preamble,
-    files,
-    $http,
-    $q,
-    $window
-  ) {
-    monkeyPatchSearch($rootScope, $compile)
-
-    return {
-      scope: {
-        theme: '=',
-        showPrintMargin: '=',
-        keybindings: '=',
-        fontSize: '=',
-        autoComplete: '=',
-        autoPairDelimiters: '=',
-        sharejsDoc: '=',
-        spellCheck: '=',
-        spellCheckLanguage: '=',
-        highlights: '=',
-        text: '=',
-        readOnly: '=',
-        annotations: '=',
-        navigateHighlights: '=',
-        fileName: '=',
-        onCtrlEnter: '=', // Compile
-        onCtrlJ: '=', // Toggle the review panel
-        onCtrlShiftC: '=', // Add a new comment
-        onCtrlShiftA: '=', // Toggle track-changes on/off
-        onSave: '=', // Cmd/Ctrl-S or :w in Vim
-        syntaxValidation: '=',
-        reviewPanel: '=',
-        eventsBridge: '=',
-        trackChanges: '=',
-        docId: '=',
-        rendererData: '=',
-        lineHeight: '=',
-        fontFamily: '='
-      },
-      link(scope, element, attrs) {
-        // Don't freak out if we're already in an apply callback
-        let spellCheckManager
-        scope.$originalApply = scope.$apply
-        scope.$apply = function(fn) {
-          if (fn == null) {
-            fn = function() {}
-          }
-          const phase = this.$root.$$phase
-          if (phase === '$apply' || phase === '$digest') {
-            return fn()
-          } else {
-            return this.$originalApply(fn)
-          }
+}
+
+App.directive('aceEditor', function(
+  $timeout,
+  $compile,
+  $rootScope,
+  eventTracking,
+  localStorage,
+  $cacheFactory,
+  metadata,
+  graphics,
+  preamble,
+  files,
+  $http,
+  $q,
+  $window
+) {
+  monkeyPatchSearch($rootScope, $compile)
+
+  return {
+    scope: {
+      theme: '=',
+      showPrintMargin: '=',
+      keybindings: '=',
+      fontSize: '=',
+      autoComplete: '=',
+      autoPairDelimiters: '=',
+      sharejsDoc: '=',
+      spellCheck: '=',
+      spellCheckLanguage: '=',
+      highlights: '=',
+      text: '=',
+      readOnly: '=',
+      annotations: '=',
+      navigateHighlights: '=',
+      fileName: '=',
+      onCtrlEnter: '=', // Compile
+      onCtrlJ: '=', // Toggle the review panel
+      onCtrlShiftC: '=', // Add a new comment
+      onCtrlShiftA: '=', // Toggle track-changes on/off
+      onSave: '=', // Cmd/Ctrl-S or :w in Vim
+      syntaxValidation: '=',
+      reviewPanel: '=',
+      eventsBridge: '=',
+      trackChanges: '=',
+      docId: '=',
+      rendererData: '=',
+      lineHeight: '=',
+      fontFamily: '='
+    },
+    link(scope, element, attrs) {
+      // Don't freak out if we're already in an apply callback
+      let spellCheckManager
+      scope.$originalApply = scope.$apply
+      scope.$apply = function(fn) {
+        if (fn == null) {
+          fn = function() {}
+        }
+        const phase = this.$root.$$phase
+        if (phase === '$apply' || phase === '$digest') {
+          return fn()
+        } else {
+          return this.$originalApply(fn)
         }
+      }
 
-        const editor = ace.edit(element.find('.ace-editor-body')[0])
-        editor.$blockScrolling = Infinity
+      const editor = ace.edit(element.find('.ace-editor-body')[0])
+      editor.$blockScrolling = Infinity
 
-        // auto-insertion of braces, brackets, dollars
-        editor.setOption('behavioursEnabled', scope.autoPairDelimiters || false)
-        editor.setOption('wrapBehavioursEnabled', false)
+      // auto-insertion of braces, brackets, dollars
+      editor.setOption('behavioursEnabled', scope.autoPairDelimiters || false)
+      editor.setOption('wrapBehavioursEnabled', false)
 
-        scope.$watch('autoPairDelimiters', autoPairDelimiters => {
-          if (autoPairDelimiters) {
-            return editor.setOption('behavioursEnabled', true)
-          } else {
-            return editor.setOption('behavioursEnabled', false)
-          }
-        })
-
-        if (!window._debug_editors) {
-          window._debug_editors = []
+      scope.$watch('autoPairDelimiters', autoPairDelimiters => {
+        if (autoPairDelimiters) {
+          return editor.setOption('behavioursEnabled', true)
+        } else {
+          return editor.setOption('behavioursEnabled', false)
         }
-        window._debug_editors.push(editor)
+      })
 
-        scope.name = attrs.aceEditor
+      if (!window._debug_editors) {
+        window._debug_editors = []
+      }
+      window._debug_editors.push(editor)
 
-        if (scope.spellCheck) {
-          // only enable spellcheck when explicitly required
-          spellCheckManager = new SpellCheckManager(
-            scope,
-            $cacheFactory,
-            $http,
-            $q,
-            new SpellCheckAdapter(editor)
-          )
-        }
+      scope.name = attrs.aceEditor
 
-        /* eslint-disable no-unused-vars */
-        const undoManager = new UndoManager(editor)
-        const highlightsManager = new HighlightsManager(scope, editor, element)
-        const cursorPositionManager = new CursorPositionManager(
-          scope,
-          new CursorPositionAdapter(editor),
-          localStorage
-        )
-        const trackChangesManager = new TrackChangesManager(
+      if (scope.spellCheck) {
+        // only enable spellcheck when explicitly required
+        spellCheckManager = new SpellCheckManager(
           scope,
-          editor,
-          element,
-          new TrackChangesAdapter(editor)
+          $cacheFactory,
+          $http,
+          $q,
+          new SpellCheckAdapter(editor)
         )
+      }
+
+      /* eslint-disable no-unused-vars */
+      const undoManager = new UndoManager(editor)
+      const highlightsManager = new HighlightsManager(scope, editor, element)
+      const cursorPositionManager = new CursorPositionManager(
+        scope,
+        new CursorPositionAdapter(editor),
+        localStorage
+      )
+      const trackChangesManager = new TrackChangesManager(
+        scope,
+        editor,
+        element,
+        new TrackChangesAdapter(editor)
+      )
+
+      const metadataManager = new MetadataManager(
+        scope,
+        editor,
+        element,
+        metadata
+      )
+      const autoCompleteManager = new AutoCompleteManager(
+        scope,
+        editor,
+        element,
+        metadataManager,
+        graphics,
+        preamble,
+        files
+      )
+
+      // prevent user entering null and non-BMP unicode characters in Ace
+      const BAD_CHARS_REGEXP = /[\0\uD800-\uDFFF]/g
+      const BAD_CHARS_REPLACEMENT_CHAR = '\uFFFD'
+      // the 'exec' event fires for ace functions before they are executed.
+      // you can modify the input or reject the event with e.preventDefault()
+      editor.commands.on('exec', function(e) {
+        // replace bad characters in paste content
+        if (e.command && e.command.name === 'paste') {
+          BAD_CHARS_REGEXP.lastIndex = 0 // reset stateful regexp for this usage
+          if (e.args && BAD_CHARS_REGEXP.test(e.args.text)) {
+            e.args.text = e.args.text.replace(
+              BAD_CHARS_REGEXP,
+              BAD_CHARS_REPLACEMENT_CHAR
+            )
+          }
+        }
+        // replace bad characters in keyboard input
+        if (e.command && e.command.name === 'insertstring') {
+          BAD_CHARS_REGEXP.lastIndex = 0 // reset stateful regexp for this usage
+          if (e.args && BAD_CHARS_REGEXP.test(e.args)) {
+            e.args = e.args.replace(
+              BAD_CHARS_REGEXP,
+              BAD_CHARS_REPLACEMENT_CHAR
+            )
+          }
+        }
+      })
+
+      /* eslint-enable no-unused-vars */
+
+      scope.$watch('onSave', function(callback) {
+        if (callback != null) {
+          Vim.defineEx('write', 'w', callback)
+          editor.commands.addCommand({
+            name: 'save',
+            bindKey: {
+              win: 'Ctrl-S',
+              mac: 'Command-S'
+            },
+            exec: callback,
+            readOnly: true
+          })
+          // Not technically 'save', but Ctrl-. recompiles in OL v1
+          // so maintain compatibility
+          return editor.commands.addCommand({
+            name: 'recompile_v1',
+            bindKey: {
+              win: 'Ctrl-.',
+              mac: 'Ctrl-.'
+            },
+            exec: callback,
+            readOnly: true
+          })
+        }
+      })
+      editor.commands.removeCommand('transposeletters')
+      editor.commands.removeCommand('showSettingsMenu')
+      editor.commands.removeCommand('foldall')
+
+      // For European keyboards, the / is above 7 so needs Shift pressing.
+      // This comes through as Command-Shift-/ on OS X, which is mapped to
+      // toggleBlockComment.
+      // This doesn't do anything for LaTeX, so remap this to togglecomment to
+      // work for European keyboards as normal.
+      // On Windows, the key combo comes as Ctrl-Shift-7.
+      editor.commands.removeCommand('toggleBlockComment')
+      editor.commands.removeCommand('togglecomment')
+
+      editor.commands.addCommand({
+        name: 'togglecomment',
+        bindKey: {
+          win: 'Ctrl-/|Ctrl-Shift-7',
+          mac: 'Command-/|Command-Shift-/'
+        },
+        exec(editor) {
+          return editor.toggleCommentLines()
+        },
+        multiSelectAction: 'forEachLine',
+        scrollIntoView: 'selectionPart'
+      })
+
+      // Trigger search AND replace on CMD+F
+      editor.commands.addCommand({
+        name: 'find',
+        bindKey: {
+          win: 'Ctrl-F',
+          mac: 'Command-F'
+        },
+        exec(editor) {
+          return SearchBox.Search(editor, true)
+        },
+        readOnly: true
+      })
+
+      // Bold text on CMD+B
+      editor.commands.addCommand({
+        name: 'bold',
+        bindKey: {
+          win: 'Ctrl-B',
+          mac: 'Command-B'
+        },
+        exec(editor) {
+          const selection = editor.getSelection()
+          if (selection.isEmpty()) {
+            editor.insert('\\textbf{}')
+            return editor.navigateLeft(1)
+          } else {
+            const text = editor.getCopyText()
+            return editor.insert(`\\textbf{${text}}`)
+          }
+        },
+        readOnly: false
+      })
+
+      // Italicise text on CMD+I
+      editor.commands.addCommand({
+        name: 'italics',
+        bindKey: {
+          win: 'Ctrl-I',
+          mac: 'Command-I'
+        },
+        exec(editor) {
+          const selection = editor.getSelection()
+          if (selection.isEmpty()) {
+            editor.insert('\\textit{}')
+            return editor.navigateLeft(1)
+          } else {
+            const text = editor.getCopyText()
+            return editor.insert(`\\textit{${text}}`)
+          }
+        },
+        readOnly: false
+      })
+
+      scope.$watch('onCtrlEnter', function(callback) {
+        if (callback != null) {
+          return editor.commands.addCommand({
+            name: 'compile',
+            bindKey: {
+              win: 'Ctrl-Enter',
+              mac: 'Command-Enter'
+            },
+            exec: editor => {
+              return callback()
+            },
+            readOnly: true
+          })
+        }
+      })
+
+      scope.$watch('onCtrlJ', function(callback) {
+        if (callback != null) {
+          return editor.commands.addCommand({
+            name: 'toggle-review-panel',
+            bindKey: {
+              win: 'Ctrl-J',
+              mac: 'Command-J'
+            },
+            exec: editor => {
+              return callback()
+            },
+            readOnly: true
+          })
+        }
+      })
+
+      scope.$watch('onCtrlShiftC', function(callback) {
+        if (callback != null) {
+          return editor.commands.addCommand({
+            name: 'add-new-comment',
+            bindKey: {
+              win: 'Ctrl-Shift-C',
+              mac: 'Command-Shift-C'
+            },
+            exec: editor => {
+              return callback()
+            },
+            readOnly: true
+          })
+        }
+      })
+
+      scope.$watch('onCtrlShiftA', function(callback) {
+        if (callback != null) {
+          return editor.commands.addCommand({
+            name: 'toggle-track-changes',
+            bindKey: {
+              win: 'Ctrl-Shift-A',
+              mac: 'Command-Shift-A'
+            },
+            exec: editor => {
+              return callback()
+            },
+            readOnly: true
+          })
+        }
+      })
 
-        const metadataManager = new MetadataManager(
-          scope,
-          editor,
-          element,
-          metadata
+      // Make '/' work for search in vim mode.
+      editor.showCommandLine = arg => {
+        if (arg === '/') {
+          return SearchBox.Search(editor, true)
+        }
+      }
+
+      const getCursorScreenPosition = function() {
+        const session = editor.getSession()
+        const cursorPosition = session.selection.getCursor()
+        const sessionPos = session.documentToScreenPosition(
+          cursorPosition.row,
+          cursorPosition.column
         )
-        const autoCompleteManager = new AutoCompleteManager(
-          scope,
-          editor,
-          element,
-          metadataManager,
-          graphics,
-          preamble,
-          files
+        return (
+          sessionPos.row * editor.renderer.lineHeight - session.getScrollTop()
         )
-
-        // prevent user entering null and non-BMP unicode characters in Ace
-        const BAD_CHARS_REGEXP = /[\0\uD800-\uDFFF]/g
-        const BAD_CHARS_REPLACEMENT_CHAR = '\uFFFD'
-        // the 'exec' event fires for ace functions before they are executed.
-        // you can modify the input or reject the event with e.preventDefault()
-        editor.commands.on('exec', function(e) {
-          // replace bad characters in paste content
-          if (e.command && e.command.name === 'paste') {
-            BAD_CHARS_REGEXP.lastIndex = 0 // reset stateful regexp for this usage
-            if (e.args && BAD_CHARS_REGEXP.test(e.args.text)) {
-              e.args.text = e.args.text.replace(
-                BAD_CHARS_REGEXP,
-                BAD_CHARS_REPLACEMENT_CHAR
+      }
+
+      if (attrs.resizeOn != null) {
+        for (let event of Array.from(attrs.resizeOn.split(','))) {
+          scope.$on(event, function() {
+            scope.$applyAsync(() => {
+              const previousScreenPosition = getCursorScreenPosition()
+              editor.resize()
+              // Put cursor back to same vertical position on screen
+              const newScreenPosition = getCursorScreenPosition()
+              const session = editor.getSession()
+              return session.setScrollTop(
+                session.getScrollTop() +
+                  newScreenPosition -
+                  previousScreenPosition
               )
-            }
-          }
-          // replace bad characters in keyboard input
-          if (e.command && e.command.name === 'insertstring') {
-            BAD_CHARS_REGEXP.lastIndex = 0 // reset stateful regexp for this usage
-            if (e.args && BAD_CHARS_REGEXP.test(e.args)) {
-              e.args = e.args.replace(
-                BAD_CHARS_REGEXP,
-                BAD_CHARS_REPLACEMENT_CHAR
-              )
-            }
-          }
-        })
-
-        /* eslint-enable no-unused-vars */
-
-        scope.$watch('onSave', function(callback) {
-          if (callback != null) {
-            Vim.defineEx('write', 'w', callback)
-            editor.commands.addCommand({
-              name: 'save',
-              bindKey: {
-                win: 'Ctrl-S',
-                mac: 'Command-S'
-              },
-              exec: callback,
-              readOnly: true
             })
-            // Not technically 'save', but Ctrl-. recompiles in OL v1
-            // so maintain compatibility
-            return editor.commands.addCommand({
-              name: 'recompile_v1',
-              bindKey: {
-                win: 'Ctrl-.',
-                mac: 'Ctrl-.'
-              },
-              exec: callback,
-              readOnly: true
-            })
-          }
-        })
-        editor.commands.removeCommand('transposeletters')
-        editor.commands.removeCommand('showSettingsMenu')
-        editor.commands.removeCommand('foldall')
-
-        // For European keyboards, the / is above 7 so needs Shift pressing.
-        // This comes through as Command-Shift-/ on OS X, which is mapped to
-        // toggleBlockComment.
-        // This doesn't do anything for LaTeX, so remap this to togglecomment to
-        // work for European keyboards as normal.
-        // On Windows, the key combo comes as Ctrl-Shift-7.
-        editor.commands.removeCommand('toggleBlockComment')
-        editor.commands.removeCommand('togglecomment')
-
-        editor.commands.addCommand({
-          name: 'togglecomment',
-          bindKey: {
-            win: 'Ctrl-/|Ctrl-Shift-7',
-            mac: 'Command-/|Command-Shift-/'
-          },
-          exec(editor) {
-            return editor.toggleCommentLines()
-          },
-          multiSelectAction: 'forEachLine',
-          scrollIntoView: 'selectionPart'
-        })
-
-        // Trigger search AND replace on CMD+F
-        editor.commands.addCommand({
-          name: 'find',
-          bindKey: {
-            win: 'Ctrl-F',
-            mac: 'Command-F'
-          },
-          exec(editor) {
-            return SearchBox.Search(editor, true)
-          },
-          readOnly: true
-        })
+          })
+        }
+      }
+
+      scope.$on(`${scope.name}:set-scroll-size`, function(e, size) {
+        // Make sure that the editor has enough scroll margin above and below
+        // to scroll the review panel with the given size
+        const marginTop = size.overflowTop
+        const { maxHeight } = editor.renderer.layerConfig
+        const marginBottom = Math.max(size.height - maxHeight, 0)
+        return setScrollMargins(marginTop, marginBottom)
+      })
+
+      var setScrollMargins = function(marginTop, marginBottom) {
+        let marginChanged = false
+        if (editor.renderer.scrollMargin.top !== marginTop) {
+          editor.renderer.scrollMargin.top = marginTop
+          marginChanged = true
+        }
+        if (editor.renderer.scrollMargin.bottom !== marginBottom) {
+          editor.renderer.scrollMargin.bottom = marginBottom
+          marginChanged = true
+        }
+        if (marginChanged) {
+          return editor.renderer.updateFull()
+        }
+      }
 
-        // Bold text on CMD+B
-        editor.commands.addCommand({
-          name: 'bold',
-          bindKey: {
-            win: 'Ctrl-B',
-            mac: 'Command-B'
-          },
-          exec(editor) {
-            const selection = editor.getSelection()
-            if (selection.isEmpty()) {
-              editor.insert('\\textbf{}')
-              return editor.navigateLeft(1)
-            } else {
-              const text = editor.getCopyText()
-              return editor.insert(`\\textbf{${text}}`)
-            }
-          },
-          readOnly: false
-        })
+      const resetScrollMargins = () => setScrollMargins(0, 0)
 
-        // Italicise text on CMD+I
-        editor.commands.addCommand({
-          name: 'italics',
-          bindKey: {
-            win: 'Ctrl-I',
-            mac: 'Command-I'
-          },
-          exec(editor) {
-            const selection = editor.getSelection()
-            if (selection.isEmpty()) {
-              editor.insert('\\textit{}')
-              return editor.navigateLeft(1)
-            } else {
-              const text = editor.getCopyText()
-              return editor.insert(`\\textit{${text}}`)
-            }
-          },
-          readOnly: false
-        })
+      scope.$watch('theme', value => editor.setTheme(`ace/theme/${value}`))
 
-        scope.$watch('onCtrlEnter', function(callback) {
-          if (callback != null) {
-            return editor.commands.addCommand({
-              name: 'compile',
-              bindKey: {
-                win: 'Ctrl-Enter',
-                mac: 'Command-Enter'
-              },
-              exec: editor => {
-                return callback()
-              },
-              readOnly: true
-            })
-          }
-        })
+      scope.$watch('showPrintMargin', value => editor.setShowPrintMargin(value))
 
-        scope.$watch('onCtrlJ', function(callback) {
-          if (callback != null) {
-            return editor.commands.addCommand({
-              name: 'toggle-review-panel',
-              bindKey: {
-                win: 'Ctrl-J',
-                mac: 'Command-J'
-              },
-              exec: editor => {
-                return callback()
-              },
-              readOnly: true
-            })
-          }
-        })
+      scope.$watch('keybindings', function(value) {
+        if (['vim', 'emacs'].includes(value)) {
+          return editor.setKeyboardHandler(`ace/keyboard/${value}`)
+        } else {
+          return editor.setKeyboardHandler(null)
+        }
+      })
 
-        scope.$watch('onCtrlShiftC', function(callback) {
-          if (callback != null) {
-            return editor.commands.addCommand({
-              name: 'add-new-comment',
-              bindKey: {
-                win: 'Ctrl-Shift-C',
-                mac: 'Command-Shift-C'
-              },
-              exec: editor => {
-                return callback()
-              },
-              readOnly: true
-            })
-          }
+      scope.$watch('fontSize', value =>
+        element.find('.ace_editor, .ace_content').css({
+          'font-size': value + 'px'
         })
-
-        scope.$watch('onCtrlShiftA', function(callback) {
-          if (callback != null) {
-            return editor.commands.addCommand({
-              name: 'toggle-track-changes',
-              bindKey: {
-                win: 'Ctrl-Shift-A',
-                mac: 'Command-Shift-A'
-              },
-              exec: editor => {
-                return callback()
-              },
-              readOnly: true
-            })
-          }
-        })
-
-        // Make '/' work for search in vim mode.
-        editor.showCommandLine = arg => {
-          if (arg === '/') {
-            return SearchBox.Search(editor, true)
+      )
+
+      scope.$watch('fontFamily', function(value) {
+        const monospaceFamilies = [
+          'Monaco',
+          'Menlo',
+          'Ubuntu Mono',
+          'Consolas',
+          'source-code-pro',
+          'monospace'
+        ]
+
+        if (value != null) {
+          switch (value) {
+            case 'monaco':
+              return editor.setOption(
+                'fontFamily',
+                monospaceFamilies.join(', ')
+              )
+            case 'lucida':
+              return editor.setOption(
+                'fontFamily',
+                '"Lucida Console", monospace'
+              )
+            default:
+              return editor.setOption('fontFamily', null)
           }
         }
-
-        const getCursorScreenPosition = function() {
-          const session = editor.getSession()
-          const cursorPosition = session.selection.getCursor()
-          const sessionPos = session.documentToScreenPosition(
-            cursorPosition.row,
-            cursorPosition.column
-          )
-          return (
-            sessionPos.row * editor.renderer.lineHeight - session.getScrollTop()
-          )
+      })
+
+      scope.$watch('lineHeight', function(value) {
+        if (value != null) {
+          switch (value) {
+            case 'compact':
+              editor.container.style.lineHeight = 1.33
+              break
+            case 'normal':
+              editor.container.style.lineHeight = 1.6
+              break
+            case 'wide':
+              editor.container.style.lineHeight = 2
+              break
+            default:
+              editor.container.style.lineHeight = 1.6
+          }
+          return editor.renderer.updateFontSize()
         }
+      })
 
-        if (attrs.resizeOn != null) {
-          for (let event of Array.from(attrs.resizeOn.split(','))) {
-            scope.$on(event, function() {
-              scope.$applyAsync(() => {
-                const previousScreenPosition = getCursorScreenPosition()
-                editor.resize()
-                // Put cursor back to same vertical position on screen
-                const newScreenPosition = getCursorScreenPosition()
-                const session = editor.getSession()
-                return session.setScrollTop(
-                  session.getScrollTop() +
-                    newScreenPosition -
-                    previousScreenPosition
-                )
-              })
-            })
-          }
+      scope.$watch('sharejsDoc', function(sharejs_doc, old_sharejs_doc) {
+        if (old_sharejs_doc != null) {
+          scope.$broadcast('beforeChangeDocument')
+          detachFromAce(old_sharejs_doc)
         }
-
-        scope.$on(`${scope.name}:set-scroll-size`, function(e, size) {
-          // Make sure that the editor has enough scroll margin above and below
-          // to scroll the review panel with the given size
-          const marginTop = size.overflowTop
-          const { maxHeight } = editor.renderer.layerConfig
-          const marginBottom = Math.max(size.height - maxHeight, 0)
-          return setScrollMargins(marginTop, marginBottom)
-        })
-
-        var setScrollMargins = function(marginTop, marginBottom) {
-          let marginChanged = false
-          if (editor.renderer.scrollMargin.top !== marginTop) {
-            editor.renderer.scrollMargin.top = marginTop
-            marginChanged = true
-          }
-          if (editor.renderer.scrollMargin.bottom !== marginBottom) {
-            editor.renderer.scrollMargin.bottom = marginBottom
-            marginChanged = true
-          }
-          if (marginChanged) {
-            return editor.renderer.updateFull()
-          }
+        if (sharejs_doc != null) {
+          attachToAce(sharejs_doc)
         }
+        if (sharejs_doc != null && old_sharejs_doc != null) {
+          return scope.$broadcast('afterChangeDocument')
+        }
+      })
 
-        const resetScrollMargins = () => setScrollMargins(0, 0)
-
-        scope.$watch('theme', value => editor.setTheme(`ace/theme/${value}`))
-
-        scope.$watch('showPrintMargin', value =>
-          editor.setShowPrintMargin(value)
-        )
-
-        scope.$watch('keybindings', function(value) {
-          if (['vim', 'emacs'].includes(value)) {
-            return editor.setKeyboardHandler(`ace/keyboard/${value}`)
-          } else {
-            return editor.setKeyboardHandler(null)
-          }
-        })
-
-        scope.$watch('fontSize', value =>
-          element.find('.ace_editor, .ace_content').css({
-            'font-size': value + 'px'
-          })
-        )
-
-        scope.$watch('fontFamily', function(value) {
-          const monospaceFamilies = [
-            'Monaco',
-            'Menlo',
-            'Ubuntu Mono',
-            'Consolas',
-            'source-code-pro',
-            'monospace'
-          ]
-
-          if (value != null) {
-            switch (value) {
-              case 'monaco':
-                return editor.setOption(
-                  'fontFamily',
-                  monospaceFamilies.join(', ')
-                )
-              case 'lucida':
-                return editor.setOption(
-                  'fontFamily',
-                  '"Lucida Console", monospace'
-                )
-              default:
-                return editor.setOption('fontFamily', null)
-            }
-          }
-        })
-
-        scope.$watch('lineHeight', function(value) {
-          if (value != null) {
-            switch (value) {
-              case 'compact':
-                editor.container.style.lineHeight = 1.33
-                break
-              case 'normal':
-                editor.container.style.lineHeight = 1.6
-                break
-              case 'wide':
-                editor.container.style.lineHeight = 2
-                break
-              default:
-                editor.container.style.lineHeight = 1.6
-            }
-            return editor.renderer.updateFontSize()
-          }
-        })
+      scope.$watch('text', function(text) {
+        if (text != null) {
+          editor.setValue(text, -1)
+          const session = editor.getSession()
+          return session.setUseWrapMode(true)
+        }
+      })
 
-        scope.$watch('sharejsDoc', function(sharejs_doc, old_sharejs_doc) {
-          if (old_sharejs_doc != null) {
-            scope.$broadcast('beforeChangeDocument')
-            detachFromAce(old_sharejs_doc)
-          }
-          if (sharejs_doc != null) {
-            attachToAce(sharejs_doc)
-          }
-          if (sharejs_doc != null && old_sharejs_doc != null) {
-            return scope.$broadcast('afterChangeDocument')
-          }
-        })
+      scope.$watch('annotations', function(annotations) {
+        const session = editor.getSession()
+        return session.setAnnotations(annotations)
+      })
 
-        scope.$watch('text', function(text) {
-          if (text != null) {
-            editor.setValue(text, -1)
-            const session = editor.getSession()
-            return session.setUseWrapMode(true)
-          }
-        })
+      scope.$watch('readOnly', value => editor.setReadOnly(!!value))
 
-        scope.$watch('annotations', function(annotations) {
+      scope.$watch('syntaxValidation', function(value) {
+        // ignore undefined settings here
+        // only instances of ace with an explicit value should set useWorker
+        // the history instance will have syntaxValidation undefined
+        if (value != null && syntaxValidationEnabled) {
           const session = editor.getSession()
-          return session.setAnnotations(annotations)
-        })
-
-        scope.$watch('readOnly', value => editor.setReadOnly(!!value))
-
-        scope.$watch('syntaxValidation', function(value) {
-          // ignore undefined settings here
-          // only instances of ace with an explicit value should set useWorker
-          // the history instance will have syntaxValidation undefined
-          if (value != null && syntaxValidationEnabled) {
-            const session = editor.getSession()
-            return session.setOption('useWorker', value)
-          }
-        })
+          return session.setOption('useWorker', value)
+        }
+      })
 
-        editor.setOption('scrollPastEnd', true)
+      editor.setOption('scrollPastEnd', true)
 
-        let updateCount = 0
-        const onChange = function() {
-          updateCount++
+      let updateCount = 0
+      const onChange = function() {
+        updateCount++
 
-          if (updateCount === 100) {
-            eventTracking.send('editor-interaction', 'multi-doc-update')
-          }
-          return scope.$emit(`${scope.name}:change`)
+        if (updateCount === 100) {
+          eventTracking.send('editor-interaction', 'multi-doc-update')
         }
+        return scope.$emit(`${scope.name}:change`)
+      }
 
-        const onScroll = function(scrollTop) {
-          if (scope.eventsBridge == null) {
-            return
-          }
-          const height = editor.renderer.layerConfig.maxHeight
-          return scope.eventsBridge.emit('aceScroll', scrollTop, height)
+      const onScroll = function(scrollTop) {
+        if (scope.eventsBridge == null) {
+          return
         }
+        const height = editor.renderer.layerConfig.maxHeight
+        return scope.eventsBridge.emit('aceScroll', scrollTop, height)
+      }
 
-        const onScrollbarVisibilityChanged = function(event, vRenderer) {
-          if (scope.eventsBridge == null) {
-            return
-          }
-          return scope.eventsBridge.emit(
-            'aceScrollbarVisibilityChanged',
-            vRenderer.scrollBarV.isVisible,
-            vRenderer.scrollBarV.width
-          )
+      const onScrollbarVisibilityChanged = function(event, vRenderer) {
+        if (scope.eventsBridge == null) {
+          return
         }
+        return scope.eventsBridge.emit(
+          'aceScrollbarVisibilityChanged',
+          vRenderer.scrollBarV.isVisible,
+          vRenderer.scrollBarV.width
+        )
+      }
 
-        if (scope.eventsBridge != null) {
-          editor.renderer.on(
-            'scrollbarVisibilityChanged',
-            onScrollbarVisibilityChanged
-          )
+      if (scope.eventsBridge != null) {
+        editor.renderer.on(
+          'scrollbarVisibilityChanged',
+          onScrollbarVisibilityChanged
+        )
 
-          scope.eventsBridge.on('externalScroll', position =>
-            editor.getSession().setScrollTop(position)
-          )
-          scope.eventsBridge.on('refreshScrollPosition', function() {
-            const session = editor.getSession()
-            session.setScrollTop(session.getScrollTop() + 1)
-            return session.setScrollTop(session.getScrollTop() - 1)
-          })
-        }
+        scope.eventsBridge.on('externalScroll', position =>
+          editor.getSession().setScrollTop(position)
+        )
+        scope.eventsBridge.on('refreshScrollPosition', function() {
+          const session = editor.getSession()
+          session.setScrollTop(session.getScrollTop() + 1)
+          return session.setScrollTop(session.getScrollTop() - 1)
+        })
+      }
 
-        const onSessionChangeForSpellCheck = function(e) {
-          spellCheckManager.onSessionChange()
-          if (e.oldSession != null) {
-            e.oldSession.getDocument().off('change', spellCheckManager.onChange)
-          }
-          e.session.getDocument().on('change', spellCheckManager.onChange)
-          if (e.oldSession != null) {
-            e.oldSession.off('changeScrollTop', spellCheckManager.onScroll)
-          }
-          return e.session.on('changeScrollTop', spellCheckManager.onScroll)
+      const onSessionChangeForSpellCheck = function(e) {
+        spellCheckManager.onSessionChange()
+        if (e.oldSession != null) {
+          e.oldSession.getDocument().off('change', spellCheckManager.onChange)
         }
-
-        const initSpellCheck = function() {
-          if (!spellCheckManager) return
-          spellCheckManager.init()
-          editor.on('changeSession', onSessionChangeForSpellCheck)
-          onSessionChangeForSpellCheck({
-            session: editor.getSession()
-          }) // Force initial setup
-          return editor.on('nativecontextmenu', spellCheckManager.onContextMenu)
+        e.session.getDocument().on('change', spellCheckManager.onChange)
+        if (e.oldSession != null) {
+          e.oldSession.off('changeScrollTop', spellCheckManager.onScroll)
         }
-
-        const tearDownSpellCheck = function() {
-          if (!spellCheckManager) return
-          editor.off('changeSession', onSessionChangeForSpellCheck)
-          return editor.off(
-            'nativecontextmenu',
-            spellCheckManager.onContextMenu
+        return e.session.on('changeScrollTop', spellCheckManager.onScroll)
+      }
+
+      const initSpellCheck = function() {
+        if (!spellCheckManager) return
+        spellCheckManager.init()
+        editor.on('changeSession', onSessionChangeForSpellCheck)
+        onSessionChangeForSpellCheck({
+          session: editor.getSession()
+        }) // Force initial setup
+        return editor.on('nativecontextmenu', spellCheckManager.onContextMenu)
+      }
+
+      const tearDownSpellCheck = function() {
+        if (!spellCheckManager) return
+        editor.off('changeSession', onSessionChangeForSpellCheck)
+        return editor.off('nativecontextmenu', spellCheckManager.onContextMenu)
+      }
+
+      const initTrackChanges = function() {
+        trackChangesManager.rangesTracker = scope.sharejsDoc.ranges
+
+        // Force onChangeSession in order to set up highlights etc.
+        trackChangesManager.onChangeSession()
+
+        if (!trackChangesManager) return
+        editor.on('changeSelection', trackChangesManager.onChangeSelection)
+
+        // Selection also moves with updates elsewhere in the document
+        editor.on('change', trackChangesManager.onChangeSelection)
+
+        editor.on('changeSession', trackChangesManager.onChangeSession)
+        editor.on('cut', trackChangesManager.onCut)
+        editor.on('paste', trackChangesManager.onPaste)
+        editor.renderer.on('resize', trackChangesManager.onResize)
+      }
+
+      const tearDownTrackChanges = function() {
+        if (!trackChangesManager) return
+        trackChangesManager.tearDown()
+        editor.off('changeSelection', trackChangesManager.onChangeSelection)
+
+        editor.off('change', trackChangesManager.onChangeSelection)
+        editor.off('changeSession', trackChangesManager.onChangeSession)
+        editor.off('cut', trackChangesManager.onCut)
+        editor.off('paste', trackChangesManager.onPaste)
+        editor.renderer.off('resize', trackChangesManager.onResize)
+      }
+
+      const initUndo = function() {
+        // Emulate onChangeSession event. Note: listening to changeSession
+        // event is unnecessary since this method is called when we switch
+        // sessions (via ShareJS changing) anyway
+        undoManager.onChangeSession(editor.getSession())
+        editor.on('change', undoManager.onChange)
+      }
+
+      const tearDownUndo = function() {
+        editor.off('change', undoManager.onChange)
+      }
+
+      const onSessionChangeForCursorPosition = function(e) {
+        if (e.oldSession != null) {
+          e.oldSession.selection.off(
+            'changeCursor',
+            cursorPositionManager.onCursorChange
           )
         }
+        return e.session.selection.on(
+          'changeCursor',
+          cursorPositionManager.onCursorChange
+        )
+      }
 
-        const initTrackChanges = function() {
-          trackChangesManager.rangesTracker = scope.sharejsDoc.ranges
-
-          // Force onChangeSession in order to set up highlights etc.
-          trackChangesManager.onChangeSession()
+      const onUnloadForCursorPosition = () =>
+        cursorPositionManager.onUnload(editor.getSession())
 
-          if (!trackChangesManager) return
-          editor.on('changeSelection', trackChangesManager.onChangeSelection)
+      const initCursorPosition = function() {
+        editor.on('changeSession', onSessionChangeForCursorPosition)
 
-          // Selection also moves with updates elsewhere in the document
-          editor.on('change', trackChangesManager.onChangeSelection)
+        // Force initial setup
+        onSessionChangeForCursorPosition({ session: editor.getSession() })
 
-          editor.on('changeSession', trackChangesManager.onChangeSession)
-          editor.on('cut', trackChangesManager.onCut)
-          editor.on('paste', trackChangesManager.onPaste)
-          editor.renderer.on('resize', trackChangesManager.onResize)
-        }
+        return $(window).on('unload', onUnloadForCursorPosition)
+      }
 
-        const tearDownTrackChanges = function() {
-          if (!trackChangesManager) return
-          this.trackChangesManager.tearDown()
-          editor.off('changeSelection', trackChangesManager.onChangeSelection)
+      const tearDownCursorPosition = function() {
+        editor.off('changeSession', onSessionChangeForCursorPosition)
+        return $(window).off('unload', onUnloadForCursorPosition)
+      }
 
-          editor.off('change', trackChangesManager.onChangeSelection)
-          editor.off('changeSession', trackChangesManager.onChangeSession)
-          editor.off('cut', trackChangesManager.onCut)
-          editor.off('paste', trackChangesManager.onPaste)
-          editor.renderer.off('resize', trackChangesManager.onResize)
-        }
+      initCursorPosition()
 
-        const initUndo = function() {
-          // Emulate onChangeSession event. Note: listening to changeSession
-          // event is unnecessary since this method is called when we switch
-          // sessions (via ShareJS changing) anyway
-          undoManager.onChangeSession(editor.getSession())
-          editor.on('change', undoManager.onChange)
-        }
+      // Trigger the event once *only* - this is called after Ace is connected
+      // to the ShareJs instance but this event should only be triggered the
+      // first time the editor is opened. Not every time the docs opened
+      const triggerEditorInitEvent = _.once(() =>
+        scope.$broadcast('editorInit')
+      )
 
-        const tearDownUndo = function() {
-          editor.off('change', undoManager.onChange)
+      var attachToAce = function(sharejs_doc) {
+        let mode
+        const lines = sharejs_doc.getSnapshot().split('\n')
+        let session = editor.getSession()
+        if (session != null) {
+          session.destroy()
         }
 
-        const onSessionChangeForCursorPosition = function(e) {
-          if (e.oldSession != null) {
-            e.oldSession.selection.off(
-              'changeCursor',
-              cursorPositionManager.onCursorChange
-            )
+        // see if we can lookup a suitable mode from ace
+        // but fall back to text by default
+        try {
+          if (/\.(Rtex|bbl|tikz)$/i.test(scope.fileName)) {
+            // recognise Rtex and bbl as latex
+            mode = 'ace/mode/latex'
+          } else if (/\.(sty|cls|clo)$/.test(scope.fileName)) {
+            // recognise some common files as tex
+            mode = 'ace/mode/tex'
+          } else {
+            ;({ mode } = ModeList.getModeForPath(scope.fileName))
+            // we prefer plain_text mode over text mode because ace's
+            // text mode is actually for code and has unwanted
+            // indenting (see wrapMethod in ace edit_session.js)
+            if (mode === 'ace/mode/text') {
+              mode = 'ace/mode/plain_text'
+            }
           }
-          return e.session.selection.on(
-            'changeCursor',
-            cursorPositionManager.onCursorChange
-          )
+        } catch (error) {
+          mode = 'ace/mode/plain_text'
         }
 
-        const onUnloadForCursorPosition = () =>
-          cursorPositionManager.onUnload(editor.getSession())
-
-        const initCursorPosition = function() {
-          editor.on('changeSession', onSessionChangeForCursorPosition)
-
-          // Force initial setup
-          onSessionChangeForCursorPosition({ session: editor.getSession() })
-
-          return $(window).on('unload', onUnloadForCursorPosition)
+        // create our new session
+        session = new EditSession(lines, mode)
+
+        session.setUseWrapMode(true)
+        // use syntax validation only when explicitly set
+        if (
+          scope.syntaxValidation != null &&
+          syntaxValidationEnabled &&
+          !/\.bib$/.test(scope.fileName)
+        ) {
+          session.setOption('useWorker', scope.syntaxValidation)
         }
 
-        const tearDownCursorPosition = function() {
-          editor.off('changeSession', onSessionChangeForCursorPosition)
-          return $(window).off('unload', onUnloadForCursorPosition)
-        }
+        // set to readonly until document change handlers are attached
+        editor.setReadOnly(true)
 
-        initCursorPosition()
+        // now attach session to editor
+        editor.setSession(session)
 
-        // Trigger the event once *only* - this is called after Ace is connected
-        // to the ShareJs instance but this event should only be triggered the
-        // first time the editor is opened. Not every time the docs opened
-        const triggerEditorInitEvent = _.once(() =>
-          scope.$broadcast('editorInit')
-        )
+        const doc = session.getDocument()
+        doc.on('change', onChange)
 
-        var attachToAce = function(sharejs_doc) {
-          let mode
-          const lines = sharejs_doc.getSnapshot().split('\n')
-          let session = editor.getSession()
-          if (session != null) {
-            session.destroy()
-          }
+        editor.initing = true
+        sharejs_doc.attachToAce(editor)
+        editor.initing = false
 
-          // see if we can lookup a suitable mode from ace
-          // but fall back to text by default
-          try {
-            if (/\.(Rtex|bbl|tikz)$/i.test(scope.fileName)) {
-              // recognise Rtex and bbl as latex
-              mode = 'ace/mode/latex'
-            } else if (/\.(sty|cls|clo)$/.test(scope.fileName)) {
-              // recognise some common files as tex
-              mode = 'ace/mode/tex'
-            } else {
-              ;({ mode } = ModeList.getModeForPath(scope.fileName))
-              // we prefer plain_text mode over text mode because ace's
-              // text mode is actually for code and has unwanted
-              // indenting (see wrapMethod in ace edit_session.js)
-              if (mode === 'ace/mode/text') {
-                mode = 'ace/mode/plain_text'
-              }
-            }
-          } catch (error) {
-            mode = 'ace/mode/plain_text'
-          }
+        // now ready to edit document
+        // respect the readOnly setting, normally false
+        editor.setReadOnly(scope.readOnly)
+        triggerEditorInitEvent()
 
-          // create our new session
-          session = new EditSession(lines, mode)
-
-          session.setUseWrapMode(true)
-          // use syntax validation only when explicitly set
-          if (
-            scope.syntaxValidation != null &&
-            syntaxValidationEnabled &&
-            !/\.bib$/.test(scope.fileName)
-          ) {
-            session.setOption('useWorker', scope.syntaxValidation)
-          }
+        if (!scope.readOnly) {
+          initSpellCheck()
+        }
 
-          // set to readonly until document change handlers are attached
-          editor.setReadOnly(true)
+        initTrackChanges()
+        initUndo()
 
-          // now attach session to editor
-          editor.setSession(session)
+        resetScrollMargins()
 
-          const doc = session.getDocument()
-          doc.on('change', onChange)
+        // need to set annotations after attaching because attaching
+        // deletes and then inserts document content
+        session.setAnnotations(scope.annotations)
 
-          editor.initing = true
-          sharejs_doc.attachToAce(editor)
-          editor.initing = false
+        session.on('changeScrollTop', eventTracking.editingSessionHeartbeat)
 
-          // now ready to edit document
-          // respect the readOnly setting, normally false
-          editor.setReadOnly(scope.readOnly)
-          triggerEditorInitEvent()
+        angular
+          .element($window)
+          .on('click', eventTracking.editingSessionHeartbeat)
 
-          if (!scope.readOnly) {
-            initSpellCheck()
-          }
+        scope.$on('$destroy', () =>
+          angular
+            .element($window)
+            .off('click', eventTracking.editingSessionHeartbeat)
+        )
 
-          initTrackChanges()
-          initUndo()
+        if (scope.eventsBridge != null) {
+          session.on('changeScrollTop', onScroll)
+        }
 
-          resetScrollMargins()
+        $rootScope.hasLintingError = false
+        session.on('changeAnnotation', function() {
+          // Both linter errors and compile logs are set as error annotations,
+          // however when the user types something, the compile logs are
+          // replaced with linter errors. When we check for lint errors before
+          // autocompile we are guaranteed to get linter errors
+          const hasErrors =
+            session
+              .getAnnotations()
+              .filter(annotation => annotation.type !== 'info').length > 0
 
-          // need to set annotations after attaching because attaching
-          // deletes and then inserts document content
-          session.setAnnotations(scope.annotations)
+          if ($rootScope.hasLintingError !== hasErrors) {
+            return ($rootScope.hasLintingError = hasErrors)
+          }
+        })
 
-          session.on('changeScrollTop', eventTracking.editingSessionHeartbeat)
+        setTimeout(() =>
+          // Let any listeners init themselves
+          onScroll(editor.renderer.getScrollTop())
+        )
 
-          angular
-            .element($window)
-            .on('click', eventTracking.editingSessionHeartbeat)
+        return editor.focus()
+      }
 
-          scope.$on('$destroy', () =>
-            angular
-              .element($window)
-              .off('click', eventTracking.editingSessionHeartbeat)
-          )
+      var detachFromAce = function(sharejs_doc) {
+        tearDownSpellCheck()
+        tearDownTrackChanges()
+        tearDownUndo()
+        sharejs_doc.detachFromAce()
+        sharejs_doc.off('remoteop.recordRemote')
 
-          if (scope.eventsBridge != null) {
-            session.on('changeScrollTop', onScroll)
-          }
+        const session = editor.getSession()
+        session.off('changeScrollTop')
 
-          $rootScope.hasLintingError = false
-          session.on('changeAnnotation', function() {
-            // Both linter errors and compile logs are set as error annotations,
-            // however when the user types something, the compile logs are
-            // replaced with linter errors. When we check for lint errors before
-            // autocompile we are guaranteed to get linter errors
-            const hasErrors =
-              session
-                .getAnnotations()
-                .filter(annotation => annotation.type !== 'info').length > 0
-
-            if ($rootScope.hasLintingError !== hasErrors) {
-              return ($rootScope.hasLintingError = hasErrors)
-            }
-          })
+        const doc = session.getDocument()
+        return doc.off('change', onChange)
+      }
 
-          setTimeout(() =>
-            // Let any listeners init themselves
-            onScroll(editor.renderer.getScrollTop())
+      if (scope.rendererData != null) {
+        editor.renderer.on('changeCharacterSize', () => {
+          scope.$apply(
+            () => (scope.rendererData.lineHeight = editor.renderer.lineHeight)
           )
+        })
+      }
 
-          return editor.focus()
+      scope.$watch('rendererData', function(rendererData) {
+        if (rendererData != null) {
+          return (rendererData.lineHeight = editor.renderer.lineHeight)
         }
+      })
 
-        var detachFromAce = function(sharejs_doc) {
+      scope.$on('$destroy', function() {
+        if (scope.sharejsDoc != null) {
+          scope.$broadcast('changeEditor')
           tearDownSpellCheck()
-          tearDownTrackChanges()
+          tearDownCursorPosition()
           tearDownUndo()
-          sharejs_doc.detachFromAce()
-          sharejs_doc.off('remoteop.recordRemote')
-
+          detachFromAce(scope.sharejsDoc)
           const session = editor.getSession()
-          session.off('changeScrollTop')
-
-          const doc = session.getDocument()
-          return doc.off('change', onChange)
-        }
-
-        if (scope.rendererData != null) {
-          editor.renderer.on('changeCharacterSize', () => {
-            scope.$apply(
-              () => (scope.rendererData.lineHeight = editor.renderer.lineHeight)
-            )
-          })
-        }
-
-        scope.$watch('rendererData', function(rendererData) {
-          if (rendererData != null) {
-            return (rendererData.lineHeight = editor.renderer.lineHeight)
-          }
-        })
-
-        scope.$on('$destroy', function() {
-          if (scope.sharejsDoc != null) {
-            scope.$broadcast('changeEditor')
-            tearDownSpellCheck()
-            tearDownCursorPosition()
-            tearDownUndo()
-            detachFromAce(scope.sharejsDoc)
-            const session = editor.getSession()
-            if (session != null) {
-              session.destroy()
-            }
-            return scope.eventsBridge.emit(
-              'aceScrollbarVisibilityChanged',
-              false,
-              0
-            )
+          if (session != null) {
+            session.destroy()
           }
-        })
+          return scope.eventsBridge.emit(
+            'aceScrollbarVisibilityChanged',
+            false,
+            0
+          )
+        }
+      })
 
-        return scope.$emit(`${scope.name}:inited`, editor)
-      },
+      return scope.$emit(`${scope.name}:inited`, editor)
+    },
 
-      template: `\
+    template: `\
 <div class="ace-editor-wrapper">
-  <div
-    class="undo-conflict-warning alert alert-danger small"
-    ng-show="undo.show_remote_warning"
-  >
-    <strong>Watch out!</strong>
-    We had to undo some of your collaborators changes before we could undo yours.
-    <a
-      href="#"
-      class="pull-right"
-      ng-click="undo.show_remote_warning = false"
-    >Dismiss</a>
-  </div>
-  <div class="ace-editor-body"></div>
-  <spell-menu
-    open="spellMenu.open"
-    top="spellMenu.top"
-    left="spellMenu.left"
-    layout-from-bottom="spellMenu.layoutFromBottom"
-    highlight="spellMenu.highlight"
-    replace-word="replaceWord(highlight, suggestion)"
-    learn-word="learnWord(highlight)"
-  ></spell-menu>
-  <div
-    class="annotation-label"
-    ng-show="annotationLabel.show"
-    ng-style="{
-      position: 'absolute',
-      left:     annotationLabel.left,
-      right:    annotationLabel.right,
-      bottom:   annotationLabel.bottom,
-      top:      annotationLabel.top,
-      'background-color': annotationLabel.backgroundColor
-    }"
-  >
-    {{ annotationLabel.text }}
-  </div>
-
-  <a
-    href
-    class="highlights-before-label btn btn-info btn-xs"
-    ng-show="updateLabels.highlightsBefore > 0"
-    ng-click="gotoHighlightAbove()"
-  >
-    <i class="fa fa-fw fa-arrow-up"></i>
-    {{ updateLabels.highlightsBefore }} more update{{ updateLabels.highlightsBefore > 1 && "" || "s" }} above
-  </a>
-
+<div
+  class="undo-conflict-warning alert alert-danger small"
+  ng-show="undo.show_remote_warning"
+>
+  <strong>Watch out!</strong>
+  We had to undo some of your collaborators changes before we could undo yours.
   <a
-    href
-    class="highlights-after-label btn btn-info btn-xs"
-    ng-show="updateLabels.highlightsAfter > 0"
-    ng-click="gotoHighlightBelow()"
-  >
-    <i class="fa fa-fw fa-arrow-down"></i>
-    {{ updateLabels.highlightsAfter }} more update{{ updateLabels.highlightsAfter > 1 && "" || "s" }} below
-
-  </a>
+    href="#"
+    class="pull-right"
+    ng-click="undo.show_remote_warning = false"
+  >Dismiss</a>
+</div>
+<div class="ace-editor-body"></div>
+<spell-menu
+  open="spellMenu.open"
+  top="spellMenu.top"
+  left="spellMenu.left"
+  layout-from-bottom="spellMenu.layoutFromBottom"
+  highlight="spellMenu.highlight"
+  replace-word="replaceWord(highlight, suggestion)"
+  learn-word="learnWord(highlight)"
+></spell-menu>
+<div
+  class="annotation-label"
+  ng-show="annotationLabel.show"
+  ng-style="{
+    position: 'absolute',
+    left:     annotationLabel.left,
+    right:    annotationLabel.right,
+    bottom:   annotationLabel.bottom,
+    top:      annotationLabel.top,
+    'background-color': annotationLabel.backgroundColor
+  }"
+>
+  {{ annotationLabel.text }}
+</div>
+
+<a
+  href
+  class="highlights-before-label btn btn-info btn-xs"
+  ng-show="updateLabels.highlightsBefore > 0"
+  ng-click="gotoHighlightAbove()"
+>
+  <i class="fa fa-fw fa-arrow-up"></i>
+  {{ updateLabels.highlightsBefore }} more update{{ updateLabels.highlightsBefore > 1 && "" || "s" }} above
+</a>
+
+<a
+  href
+  class="highlights-after-label btn btn-info btn-xs"
+  ng-show="updateLabels.highlightsAfter > 0"
+  ng-click="gotoHighlightBelow()"
+>
+  <i class="fa fa-fw fa-arrow-down"></i>
+  {{ updateLabels.highlightsAfter }} more update{{ updateLabels.highlightsAfter > 1 && "" || "s" }} below
+
+</a>
 </div>\
 `
-    }
-  })
+  }
+})
 
-  function monkeyPatchSearch($rootScope, $compile) {
-    const searchHtml = `\
+function monkeyPatchSearch($rootScope, $compile) {
+  const searchHtml = `\
 <div class="ace_search right">
-  <a href type="button" action="hide" class="ace_searchbtn_close">
-    <i class="fa fa-fw fa-times"></i>
-  </a>
-  <div class="ace_search_form">
-    <input class="ace_search_field form-control input-sm" placeholder="Search for" spellcheck="false"></input>
-    <div class="btn-group">
-      <button type="button" action="findNext" class="ace_searchbtn next btn btn-default btn-sm">
-        <i class="fa fa-chevron-down fa-fw"></i>
-      </button>
-      <button type="button" action="findPrev" class="ace_searchbtn prev btn btn-default btn-sm">
-        <i class="fa fa-chevron-up fa-fw"></i>
-      </button>
-    </div>
+<a href type="button" action="hide" class="ace_searchbtn_close">
+  <i class="fa fa-fw fa-times"></i>
+</a>
+<div class="ace_search_form">
+  <input class="ace_search_field form-control input-sm" placeholder="Search for" spellcheck="false"></input>
+  <div class="btn-group">
+    <button type="button" action="findNext" class="ace_searchbtn next btn btn-default btn-sm">
+      <i class="fa fa-chevron-down fa-fw"></i>
+    </button>
+    <button type="button" action="findPrev" class="ace_searchbtn prev btn btn-default btn-sm">
+      <i class="fa fa-chevron-up fa-fw"></i>
+    </button>
   </div>
-  <div class="ace_replace_form">
-    <input class="ace_search_field form-control input-sm" placeholder="Replace with" spellcheck="false"></input>
-    <div class="btn-group">
-      <button type="button" action="replaceAndFindNext" class="ace_replacebtn btn btn-default btn-sm">Replace</button>
-      <button type="button" action="replaceAll" class="ace_replacebtn btn btn-default btn-sm">All</button>
-    </div>
+</div>
+<div class="ace_replace_form">
+  <input class="ace_search_field form-control input-sm" placeholder="Replace with" spellcheck="false"></input>
+  <div class="btn-group">
+    <button type="button" action="replaceAndFindNext" class="ace_replacebtn btn btn-default btn-sm">Replace</button>
+    <button type="button" action="replaceAll" class="ace_replacebtn btn btn-default btn-sm">All</button>
   </div>
-  <div class="ace_search_options">
-    <div class="btn-group">
-      <button action="toggleRegexpMode" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="RegExp Search">.*</button>
-      <button action="toggleCaseSensitive" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="CaseSensitive Search">Aa</button>
-      <button action="toggleWholeWords" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="Whole Word Search">"..."</button>
-      <button action="searchInSelection" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="Search Within Selection"><i class="fa fa-align-left"></i></button>
-    </div>
-    <span class="ace_search_counter"></span>
+</div>
+<div class="ace_search_options">
+  <div class="btn-group">
+    <button action="toggleRegexpMode" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="RegExp Search">.*</button>
+    <button action="toggleCaseSensitive" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="CaseSensitive Search">Aa</button>
+    <button action="toggleWholeWords" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="Whole Word Search">"..."</button>
+    <button action="searchInSelection" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="Search Within Selection"><i class="fa fa-align-left"></i></button>
   </div>
-  <div action="toggleReplace" class="hidden"></div>
+  <span class="ace_search_counter"></span>
+</div>
+<div action="toggleReplace" class="hidden"></div>
 </div>\
 `
 
-    // Remove Ace CSS
-    $('#ace_searchbox').remove()
+  // Remove Ace CSS
+  $('#ace_searchbox').remove()
 
-    const SB = SearchBox.SearchBox
-    const { $init } = SB.prototype
-    SB.prototype.$init = function() {
-      this.element = $compile(searchHtml)($rootScope.$new())[0]
-      return $init.apply(this)
-    }
+  const SB = SearchBox.SearchBox
+  const { $init } = SB.prototype
+  SB.prototype.$init = function() {
+    this.element = $compile(searchHtml)($rootScope.$new())[0]
+    return $init.apply(this)
   }
-})
+}

+ 403 - 415
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.js

@@ -1,486 +1,474 @@
-define([
-  './CommandManager',
-  './EnvironmentManager',
-  './PackageManager',
-  './Helpers',
-  'ace/ace',
-  'ace/ext-language_tools'
-], function(CommandManager, EnvironmentManager, PackageManager, Helpers) {
-  const { Range } = ace.require('ace/range')
-  const aceSnippetManager = ace.require('ace/snippets').snippetManager
+import CommandManager from './CommandManager'
+import EnvironmentManager from './EnvironmentManager'
+import PackageManager from './PackageManager'
+import Helpers from './Helpers'
+import 'ace/ace'
+import 'ace/ext-language_tools'
+const { Range } = ace.require('ace/range')
+const aceSnippetManager = ace.require('ace/snippets').snippetManager
 
-  class AutoCompleteManager {
-    constructor(
-      $scope,
-      editor,
-      element,
-      metadataManager,
-      graphics,
-      preamble,
-      files
-    ) {
-      this.$scope = $scope
-      this.editor = editor
-      this.element = element
-      this.metadataManager = metadataManager
-      this.graphics = graphics
-      this.preamble = preamble
-      this.files = files
-      this.monkeyPatchAutocomplete()
-
-      this.$scope.$watch('autoComplete', autocomplete => {
-        if (autocomplete) {
-          this.enable()
-        } else {
-          this.disable()
-        }
-      })
+class AutoCompleteManager {
+  constructor(
+    $scope,
+    editor,
+    element,
+    metadataManager,
+    graphics,
+    preamble,
+    files
+  ) {
+    this.$scope = $scope
+    this.editor = editor
+    this.element = element
+    this.metadataManager = metadataManager
+    this.graphics = graphics
+    this.preamble = preamble
+    this.files = files
+    this.monkeyPatchAutocomplete()
 
-      const onChange = change => {
-        this.onChange(change)
+    this.$scope.$watch('autoComplete', autocomplete => {
+      if (autocomplete) {
+        this.enable()
+      } else {
+        this.disable()
       }
+    })
 
-      this.editor.on('changeSession', e => {
-        e.oldSession.off('change', onChange)
-        e.session.on('change', onChange)
-      })
+    const onChange = change => {
+      this.onChange(change)
     }
 
-    enable() {
-      this.editor.setOptions({
-        enableBasicAutocompletion: true,
-        enableSnippets: true,
-        enableLiveAutocompletion: false
-      })
+    this.editor.on('changeSession', e => {
+      e.oldSession.off('change', onChange)
+      e.session.on('change', onChange)
+    })
+  }
 
-      const CommandCompleter = new CommandManager(this.metadataManager)
-      const SnippetCompleter = new EnvironmentManager()
-      const PackageCompleter = new PackageManager(this.metadataManager, Helpers)
+  enable() {
+    this.editor.setOptions({
+      enableBasicAutocompletion: true,
+      enableSnippets: true,
+      enableLiveAutocompletion: false
+    })
 
-      const Graphics = this.graphics
-      const Preamble = this.preamble
-      const Files = this.files
+    const CommandCompleter = new CommandManager(this.metadataManager)
+    const SnippetCompleter = new EnvironmentManager()
+    const PackageCompleter = new PackageManager(this.metadataManager, Helpers)
 
-      const GraphicsCompleter = {
-        getCompletions(editor, session, pos, prefix, callback) {
-          const { commandFragment } = Helpers.getContext(editor, pos)
-          if (commandFragment) {
-            const match = commandFragment.match(
-              /^~?\\(includegraphics(?:\[.*])?){([^}]*, *)?(\w*)/
-            )
-            if (match) {
-              // eslint-disable-next-line no-unused-vars
-              let commandName = match[1]
-              const graphicsPaths = Preamble.getGraphicsPaths()
-              const result = []
-              for (let graphic of Graphics.getGraphicsFiles()) {
-                let { path } = graphic
-                for (let graphicsPath of graphicsPaths) {
-                  if (path.indexOf(graphicsPath) === 0) {
-                    path = path.slice(graphicsPath.length)
-                    break
-                  }
-                }
-                result.push({
-                  caption: `\\${commandName}{${path}}`,
-                  value: `\\${commandName}{${path}}`,
-                  meta: 'graphic',
-                  score: 50
-                })
-              }
-              callback(null, result)
-            }
-          }
-        }
-      }
+    const Graphics = this.graphics
+    const Preamble = this.preamble
+    const Files = this.files
 
-      const { metadataManager } = this
-      const FilesCompleter = {
-        getCompletions: (editor, session, pos, prefix, callback) => {
-          const { commandFragment } = Helpers.getContext(editor, pos)
-          if (commandFragment) {
-            const match = commandFragment.match(/^\\(input|include){(\w*)/)
-            if (match) {
-              // eslint-disable-next-line no-unused-vars
-              const commandName = match[1]
-              const result = []
-              for (let file of Files.getTeXFiles()) {
-                if (
-                  file.id !== this.$scope.docId &&
-                  !file.deleted &&
-                  file.path
-                ) {
-                  const { path } = file
-                  let cleanPath = path.replace(/(.+)\.tex$/i, '$1')
-                  result.push({
-                    caption: `\\${commandName}{${path}}`,
-                    value: `\\${commandName}{${cleanPath}}`,
-                    meta: 'file',
-                    score: 50
-                  })
+    const GraphicsCompleter = {
+      getCompletions(editor, session, pos, prefix, callback) {
+        const { commandFragment } = Helpers.getContext(editor, pos)
+        if (commandFragment) {
+          const match = commandFragment.match(
+            /^~?\\(includegraphics(?:\[.*])?){([^}]*, *)?(\w*)/
+          )
+          if (match) {
+            // eslint-disable-next-line no-unused-vars
+            let commandName = match[1]
+            const graphicsPaths = Preamble.getGraphicsPaths()
+            const result = []
+            for (let graphic of Graphics.getGraphicsFiles()) {
+              let { path } = graphic
+              for (let graphicsPath of graphicsPaths) {
+                if (path.indexOf(graphicsPath) === 0) {
+                  path = path.slice(graphicsPath.length)
+                  break
                 }
               }
-              callback(null, result)
+              result.push({
+                caption: `\\${commandName}{${path}}`,
+                value: `\\${commandName}{${path}}`,
+                meta: 'graphic',
+                score: 50
+              })
             }
+            callback(null, result)
           }
         }
       }
+    }
 
-      const LabelsCompleter = {
-        getCompletions(editor, session, pos, prefix, callback) {
-          const { commandFragment } = Helpers.getContext(editor, pos)
-          if (commandFragment) {
-            const refMatch = commandFragment.match(
-              /^~?\\([a-zA-Z]*ref){([^}]*, *)?(\w*)/
-            )
-            if (refMatch) {
-              // eslint-disable-next-line no-unused-vars
-              const commandName = refMatch[1]
-              const result = []
-              if (commandName !== 'ref') {
-                // ref is in top 100 commands
+    const { metadataManager } = this
+    const FilesCompleter = {
+      getCompletions: (editor, session, pos, prefix, callback) => {
+        const { commandFragment } = Helpers.getContext(editor, pos)
+        if (commandFragment) {
+          const match = commandFragment.match(/^\\(input|include){(\w*)/)
+          if (match) {
+            // eslint-disable-next-line no-unused-vars
+            const commandName = match[1]
+            const result = []
+            for (let file of Files.getTeXFiles()) {
+              if (file.id !== this.$scope.docId && !file.deleted && file.path) {
+                const { path } = file
+                let cleanPath = path.replace(/(.+)\.tex$/i, '$1')
                 result.push({
-                  caption: `\\${commandName}{}`,
-                  snippet: `\\${commandName}{}`,
-                  meta: 'cross-reference',
-                  score: 60
-                })
-              }
-              for (let label of metadataManager.getAllLabels()) {
-                result.push({
-                  caption: `\\${commandName}{${label}}`,
-                  value: `\\${commandName}{${label}}`,
-                  meta: 'cross-reference',
+                  caption: `\\${commandName}{${path}}`,
+                  value: `\\${commandName}{${cleanPath}}`,
+                  meta: 'file',
                   score: 50
                 })
               }
-              callback(null, result)
             }
+            callback(null, result)
           }
         }
       }
+    }
 
-      const references = this.$scope.$root._references
-      const ReferencesCompleter = {
-        getCompletions(editor, session, pos, prefix, callback) {
-          const { commandFragment } = Helpers.getContext(editor, pos)
-          if (commandFragment) {
-            const citeMatch = commandFragment.match(
-              /^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/
-            )
-            if (citeMatch) {
-              // eslint-disable-next-line no-unused-vars
-              let [_ignore, commandName, previousArgs] = citeMatch
-              if (previousArgs == null) {
-                previousArgs = ''
-              }
-              const previousArgsCaption =
-                previousArgs.length > 8 ? '…,' : previousArgs
-              const result = []
+    const LabelsCompleter = {
+      getCompletions(editor, session, pos, prefix, callback) {
+        const { commandFragment } = Helpers.getContext(editor, pos)
+        if (commandFragment) {
+          const refMatch = commandFragment.match(
+            /^~?\\([a-zA-Z]*ref){([^}]*, *)?(\w*)/
+          )
+          if (refMatch) {
+            // eslint-disable-next-line no-unused-vars
+            const commandName = refMatch[1]
+            const result = []
+            if (commandName !== 'ref') {
+              // ref is in top 100 commands
               result.push({
                 caption: `\\${commandName}{}`,
                 snippet: `\\${commandName}{}`,
-                meta: 'reference',
+                meta: 'cross-reference',
                 score: 60
               })
-              if (references.keys && references.keys.length > 0) {
-                references.keys.forEach(function(key) {
-                  if (key != null) {
-                    result.push({
-                      caption: `\\${commandName}{${previousArgsCaption}${key}}`,
-                      value: `\\${commandName}{${previousArgs}${key}}`,
-                      meta: 'reference',
-                      score: 50
-                    })
-                  }
-                })
-                callback(null, result)
-              } else {
-                callback(null, result)
-              }
             }
+            for (let label of metadataManager.getAllLabels()) {
+              result.push({
+                caption: `\\${commandName}{${label}}`,
+                value: `\\${commandName}{${label}}`,
+                meta: 'cross-reference',
+                score: 50
+              })
+            }
+            callback(null, result)
           }
         }
       }
-
-      this.editor.completers = [
-        CommandCompleter,
-        SnippetCompleter,
-        PackageCompleter,
-        ReferencesCompleter,
-        LabelsCompleter,
-        GraphicsCompleter,
-        FilesCompleter
-      ]
-    }
-
-    disable() {
-      return this.editor.setOptions({
-        enableBasicAutocompletion: false,
-        enableSnippets: false
-      })
     }
 
-    onChange(change) {
-      let i
-      const cursorPosition = this.editor.getCursorPosition()
-      const { end } = change
-      const { lineUpToCursor, commandFragment } = Helpers.getContext(
-        this.editor,
-        end
-      )
-      if (
-        (i = lineUpToCursor.indexOf('%')) > -1 &&
-        lineUpToCursor[i - 1] !== '\\'
-      ) {
-        return
-      }
-      const lastCharIsBackslash = lineUpToCursor.slice(-1) === '\\'
-      const lastTwoChars = lineUpToCursor.slice(-2)
-      // Don't offer autocomplete on double-backslash, backslash-colon, etc
-      if (/^\\[^a-zA-Z]$/.test(lastTwoChars)) {
-        if (this.editor.completer) {
-          this.editor.completer.detach()
+    const references = this.$scope.$root._references
+    const ReferencesCompleter = {
+      getCompletions(editor, session, pos, prefix, callback) {
+        const { commandFragment } = Helpers.getContext(editor, pos)
+        if (commandFragment) {
+          const citeMatch = commandFragment.match(
+            /^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/
+          )
+          if (citeMatch) {
+            // eslint-disable-next-line no-unused-vars
+            let [_ignore, commandName, previousArgs] = citeMatch
+            if (previousArgs == null) {
+              previousArgs = ''
+            }
+            const previousArgsCaption =
+              previousArgs.length > 8 ? '…,' : previousArgs
+            const result = []
+            result.push({
+              caption: `\\${commandName}{}`,
+              snippet: `\\${commandName}{}`,
+              meta: 'reference',
+              score: 60
+            })
+            if (references.keys && references.keys.length > 0) {
+              references.keys.forEach(function(key) {
+                if (key != null) {
+                  result.push({
+                    caption: `\\${commandName}{${previousArgsCaption}${key}}`,
+                    value: `\\${commandName}{${previousArgs}${key}}`,
+                    meta: 'reference',
+                    score: 50
+                  })
+                }
+              })
+              callback(null, result)
+            } else {
+              callback(null, result)
+            }
+          }
         }
-        return
       }
-      // Check that this change was made by us, not a collaborator
-      // (Cursor is still one place behind)
-      // NOTE: this is also the case when a user backspaces over a highlighted
-      // region
-      if (
-        !change.remote &&
-        change.action === 'insert' &&
-        end.row === cursorPosition.row &&
-        end.column === cursorPosition.column + 1
-      ) {
-        if (
-          (commandFragment != null ? commandFragment.length : undefined) > 2 ||
-          lastCharIsBackslash
-        ) {
-          setTimeout(() => {
-            this.editor.execCommand('startAutocomplete')
-          }, 0)
-        }
+    }
+
+    this.editor.completers = [
+      CommandCompleter,
+      SnippetCompleter,
+      PackageCompleter,
+      ReferencesCompleter,
+      LabelsCompleter,
+      GraphicsCompleter,
+      FilesCompleter
+    ]
+  }
+
+  disable() {
+    return this.editor.setOptions({
+      enableBasicAutocompletion: false,
+      enableSnippets: false
+    })
+  }
+
+  onChange(change) {
+    let i
+    const cursorPosition = this.editor.getCursorPosition()
+    const { end } = change
+    const { lineUpToCursor, commandFragment } = Helpers.getContext(
+      this.editor,
+      end
+    )
+    if (
+      (i = lineUpToCursor.indexOf('%')) > -1 &&
+      lineUpToCursor[i - 1] !== '\\'
+    ) {
+      return
+    }
+    const lastCharIsBackslash = lineUpToCursor.slice(-1) === '\\'
+    const lastTwoChars = lineUpToCursor.slice(-2)
+    // Don't offer autocomplete on double-backslash, backslash-colon, etc
+    if (/^\\[^a-zA-Z]$/.test(lastTwoChars)) {
+      if (this.editor.completer) {
+        this.editor.completer.detach()
       }
-      const match = change.lines[0].match(/\\(\w+){}/)
+      return
+    }
+    // Check that this change was made by us, not a collaborator
+    // (Cursor is still one place behind)
+    // NOTE: this is also the case when a user backspaces over a highlighted
+    // region
+    if (
+      !change.remote &&
+      change.action === 'insert' &&
+      end.row === cursorPosition.row &&
+      end.column === cursorPosition.column + 1
+    ) {
       if (
-        change.action === 'insert' &&
-        (match && match[1]) &&
-        // eslint-disable-next-line max-len
-        /(begin|end|[a-zA-Z]*ref|usepackage|[a-z]*cite[a-z]*|input|include)/.test(
-          match[1]
-        )
+        (commandFragment != null ? commandFragment.length : undefined) > 2 ||
+        lastCharIsBackslash
       ) {
-        return setTimeout(() => {
+        setTimeout(() => {
           this.editor.execCommand('startAutocomplete')
         }, 0)
       }
     }
+    const match = change.lines[0].match(/\\(\w+){}/)
+    if (
+      change.action === 'insert' &&
+      (match && match[1]) &&
+      // eslint-disable-next-line max-len
+      /(begin|end|[a-zA-Z]*ref|usepackage|[a-z]*cite[a-z]*|input|include)/.test(
+        match[1]
+      )
+    ) {
+      return setTimeout(() => {
+        this.editor.execCommand('startAutocomplete')
+      }, 0)
+    }
+  }
 
-    monkeyPatchAutocomplete() {
-      const { Autocomplete } = ace.require('ace/autocomplete')
-      const Util = ace.require('ace/autocomplete/util')
+  monkeyPatchAutocomplete() {
+    const { Autocomplete } = ace.require('ace/autocomplete')
+    const Util = ace.require('ace/autocomplete/util')
 
-      if (Autocomplete.prototype._insertMatch == null) {
-        // Only override this once since it's global but we may create multiple
-        // autocomplete handlers
-        Autocomplete.prototype._insertMatch = Autocomplete.prototype.insertMatch
-        Autocomplete.prototype.insertMatch = function(data) {
-          const { editor } = this
+    if (Autocomplete.prototype._insertMatch == null) {
+      // Only override this once since it's global but we may create multiple
+      // autocomplete handlers
+      Autocomplete.prototype._insertMatch = Autocomplete.prototype.insertMatch
+      Autocomplete.prototype.insertMatch = function(data) {
+        const { editor } = this
 
-          const pos = editor.getCursorPosition()
-          let range = new Range(pos.row, pos.column, pos.row, pos.column + 1)
-          const nextChar = editor.session.getTextRange(range)
-          // If we are in \begin{it|}, then we need to remove the trailing }
-          // since it will be adding in with the autocomplete of \begin{item}...
-          if (
-            /^\\\w+(\[[\w\\,=. ]*\])?{/.test(this.completions.filterText) &&
-            nextChar === '}'
-          ) {
-            editor.session.remove(range)
-          }
+        const pos = editor.getCursorPosition()
+        let range = new Range(pos.row, pos.column, pos.row, pos.column + 1)
+        const nextChar = editor.session.getTextRange(range)
+        // If we are in \begin{it|}, then we need to remove the trailing }
+        // since it will be adding in with the autocomplete of \begin{item}...
+        if (
+          /^\\\w+(\[[\w\\,=. ]*\])?{/.test(this.completions.filterText) &&
+          nextChar === '}'
+        ) {
+          editor.session.remove(range)
+        }
 
-          // Provide our own `insertMatch` implementation.
-          // See the `insertMatch` method of Autocomplete in
-          // `ext-language_tools.js`.
-          // We need this to account for editing existing commands, particularly
-          // when adding a prefix.
-          // We fix this by detecting when the cursor is in the middle of an
-          // existing command, and adjusting the insertions/deletions
-          // accordingly.
-          // Example:
-          //   when changing `\ref{}` to `\href{}`, ace default behaviour
-          //   is likely to end up with `\href{}ref{}`
-          if (data == null) {
-            const { completions } = this
-            const { popup } = this
-            data = popup.getData(popup.getRow())
-            data.completer = {
-              insertMatch(editor, matchData) {
-                for (range of editor.selection.getAllRanges()) {
-                  const leftRange = _.clone(range)
-                  const rightRange = _.clone(range)
-                  // trim to left of cursor
-                  const lineUpToCursor = editor
-                    .getSession()
-                    .getTextRange(
-                      new Range(
-                        range.start.row,
-                        0,
-                        range.start.row,
-                        range.start.column
-                      )
+        // Provide our own `insertMatch` implementation.
+        // See the `insertMatch` method of Autocomplete in
+        // `ext-language_tools.js`.
+        // We need this to account for editing existing commands, particularly
+        // when adding a prefix.
+        // We fix this by detecting when the cursor is in the middle of an
+        // existing command, and adjusting the insertions/deletions
+        // accordingly.
+        // Example:
+        //   when changing `\ref{}` to `\href{}`, ace default behaviour
+        //   is likely to end up with `\href{}ref{}`
+        if (data == null) {
+          const { completions } = this
+          const { popup } = this
+          data = popup.getData(popup.getRow())
+          data.completer = {
+            insertMatch(editor, matchData) {
+              for (range of editor.selection.getAllRanges()) {
+                const leftRange = _.clone(range)
+                const rightRange = _.clone(range)
+                // trim to left of cursor
+                const lineUpToCursor = editor
+                  .getSession()
+                  .getTextRange(
+                    new Range(
+                      range.start.row,
+                      0,
+                      range.start.row,
+                      range.start.column
                     )
-                  // Delete back to command start, as appropriate
-                  const commandStartIndex = Helpers.getLastCommandFragmentIndex(
-                    lineUpToCursor
                   )
-                  if (commandStartIndex !== -1) {
-                    leftRange.start.column = commandStartIndex
-                  } else {
-                    leftRange.start.column -= completions.filterText.length
-                  }
-                  editor.session.remove(leftRange)
-                  // look at text after cursor
-                  const lineBeyondCursor = editor
-                    .getSession()
-                    .getTextRange(
-                      new Range(
-                        rightRange.start.row,
-                        rightRange.start.column,
-                        rightRange.end.row,
-                        99999
-                      )
+                // Delete back to command start, as appropriate
+                const commandStartIndex = Helpers.getLastCommandFragmentIndex(
+                  lineUpToCursor
+                )
+                if (commandStartIndex !== -1) {
+                  leftRange.start.column = commandStartIndex
+                } else {
+                  leftRange.start.column -= completions.filterText.length
+                }
+                editor.session.remove(leftRange)
+                // look at text after cursor
+                const lineBeyondCursor = editor
+                  .getSession()
+                  .getTextRange(
+                    new Range(
+                      rightRange.start.row,
+                      rightRange.start.column,
+                      rightRange.end.row,
+                      99999
                     )
+                  )
 
-                  if (lineBeyondCursor) {
-                    const partialCommandMatch = lineBeyondCursor.match(
-                      /^([a-zA-Z0-9]+)\{/
-                    )
-                    if (partialCommandMatch) {
-                      // We've got a partial command after the cursor
-                      const commandTail = partialCommandMatch[1]
-                      // remove rest of the partial command, right of cursor
-                      rightRange.end.column +=
-                        commandTail.length - completions.filterText.length
-                      editor.session.remove(rightRange)
-                      // trim the completion text to just the command, without
-                      // braces or brackets
-                      // example: '\cite{}' -> '\cite'
-                      if (matchData.snippet != null) {
-                        matchData.snippet = matchData.snippet.replace(
-                          /[{[].*[}\]]/,
-                          ''
-                        )
-                      }
-                      if (matchData.caption != null) {
-                        matchData.caption = matchData.caption.replace(
-                          /[{[].*[}\]]/,
-                          ''
-                        )
-                      }
-                      if (matchData.value != null) {
-                        matchData.value = matchData.value.replace(
-                          /[{[].*[}\]]/,
-                          ''
-                        )
-                      }
+                if (lineBeyondCursor) {
+                  const partialCommandMatch = lineBeyondCursor.match(
+                    /^([a-zA-Z0-9]+)\{/
+                  )
+                  if (partialCommandMatch) {
+                    // We've got a partial command after the cursor
+                    const commandTail = partialCommandMatch[1]
+                    // remove rest of the partial command, right of cursor
+                    rightRange.end.column +=
+                      commandTail.length - completions.filterText.length
+                    editor.session.remove(rightRange)
+                    // trim the completion text to just the command, without
+                    // braces or brackets
+                    // example: '\cite{}' -> '\cite'
+                    if (matchData.snippet != null) {
+                      matchData.snippet = matchData.snippet.replace(
+                        /[{[].*[}\]]/,
+                        ''
+                      )
                     }
-                    const inArgument = lineBeyondCursor.match(
-                      /^([\w._-]+)\}(.*)/
-                    )
-                    if (inArgument) {
-                      const argumentRightOfCursor = inArgument[1]
-                      const afterArgument = inArgument[2]
-                      if (afterArgument) {
-                        rightRange.end.column =
-                          rightRange.start.column +
-                          argumentRightOfCursor.length +
-                          1
-                      }
-                      editor.session.remove(rightRange)
+                    if (matchData.caption != null) {
+                      matchData.caption = matchData.caption.replace(
+                        /[{[].*[}\]]/,
+                        ''
+                      )
+                    }
+                    if (matchData.value != null) {
+                      matchData.value = matchData.value.replace(
+                        /[{[].*[}\]]/,
+                        ''
+                      )
                     }
                   }
+                  const inArgument = lineBeyondCursor.match(/^([\w._-]+)\}(.*)/)
+                  if (inArgument) {
+                    const argumentRightOfCursor = inArgument[1]
+                    const afterArgument = inArgument[2]
+                    if (afterArgument) {
+                      rightRange.end.column =
+                        rightRange.start.column +
+                        argumentRightOfCursor.length +
+                        1
+                    }
+                    editor.session.remove(rightRange)
+                  }
                 }
-                // finally, insert the match
-                if (matchData.snippet) {
-                  aceSnippetManager.insertSnippet(editor, matchData.snippet)
-                } else {
-                  editor.execCommand(
-                    'insertstring',
-                    matchData.value || matchData
-                  )
-                }
+              }
+              // finally, insert the match
+              if (matchData.snippet) {
+                aceSnippetManager.insertSnippet(editor, matchData.snippet)
+              } else {
+                editor.execCommand('insertstring', matchData.value || matchData)
               }
             }
           }
-
-          Autocomplete.prototype._insertMatch.call(this, data)
         }
 
-        // Overwrite this to set autoInsert = false and set font size
-        Autocomplete.startCommand = {
-          name: 'startAutocomplete',
-          exec: editor => {
-            let filtered
-            if (!editor.completer) {
-              editor.completer = new Autocomplete()
-            }
-            editor.completer.autoInsert = false
-            editor.completer.autoSelect = true
-            editor.completer.showPopup(editor)
-            editor.completer.cancelContextMenu()
-            const container = $(
-              editor.completer.popup != null
-                ? editor.completer.popup.container
-                : undefined
-            )
-            container.css({ 'font-size': this.$scope.fontSize + 'px' })
-            // Dynamically set width of autocomplete popup
-            filtered =
-              editor.completer.completions &&
-              editor.completer.completions.filtered
-            if (filtered) {
-              const longestCaption = _.max(filtered.map(c => c.caption.length))
-              const longestMeta = _.max(filtered.map(c => c.meta.length))
-              const charWidth = editor.renderer.characterWidth
-              // between 280 and 700 px
-              const width = Math.max(
-                Math.min(
-                  Math.round(
-                    longestCaption * charWidth +
-                      longestMeta * charWidth +
-                      5 * charWidth
-                  ),
-                  700
-                ),
-                280
-              )
-              container.css({ width: `${width}px` })
-            }
-            if (filtered.length === 0) {
-              editor.completer.detach()
-            }
-          },
-          bindKey: 'Ctrl-Space|Ctrl-Shift-Space|Alt-Space'
-        }
+        Autocomplete.prototype._insertMatch.call(this, data)
       }
 
-      Util.retrievePrecedingIdentifier = function(text, pos, regex) {
-        let currentLineOffset = 0
-        for (let i = pos - 1; i <= 0; i++) {
-          if (text[i] === '\n') {
-            currentLineOffset = i + 1
-            break
+      // Overwrite this to set autoInsert = false and set font size
+      Autocomplete.startCommand = {
+        name: 'startAutocomplete',
+        exec: editor => {
+          let filtered
+          if (!editor.completer) {
+            editor.completer = new Autocomplete()
           }
+          editor.completer.autoInsert = false
+          editor.completer.autoSelect = true
+          editor.completer.showPopup(editor)
+          editor.completer.cancelContextMenu()
+          const container = $(
+            editor.completer.popup != null
+              ? editor.completer.popup.container
+              : undefined
+          )
+          container.css({ 'font-size': this.$scope.fontSize + 'px' })
+          // Dynamically set width of autocomplete popup
+          filtered =
+            editor.completer.completions &&
+            editor.completer.completions.filtered
+          if (filtered) {
+            const longestCaption = _.max(filtered.map(c => c.caption.length))
+            const longestMeta = _.max(filtered.map(c => c.meta.length))
+            const charWidth = editor.renderer.characterWidth
+            // between 280 and 700 px
+            const width = Math.max(
+              Math.min(
+                Math.round(
+                  longestCaption * charWidth +
+                    longestMeta * charWidth +
+                    5 * charWidth
+                ),
+                700
+              ),
+              280
+            )
+            container.css({ width: `${width}px` })
+          }
+          if (filtered.length === 0) {
+            editor.completer.detach()
+          }
+        },
+        bindKey: 'Ctrl-Space|Ctrl-Shift-Space|Alt-Space'
+      }
+    }
+
+    Util.retrievePrecedingIdentifier = function(text, pos, regex) {
+      let currentLineOffset = 0
+      for (let i = pos - 1; i <= 0; i++) {
+        if (text[i] === '\n') {
+          currentLineOffset = i + 1
+          break
         }
-        const currentLine = text.slice(currentLineOffset, pos)
-        const fragment = Helpers.getLastCommandFragment(currentLine) || ''
-        return fragment
       }
+      const currentLine = text.slice(currentLineOffset, pos)
+      const fragment = Helpers.getLastCommandFragment(currentLine) || ''
+      return fragment
     }
   }
+}
 
-  return AutoCompleteManager
-})
+export default AutoCompleteManager

+ 180 - 182
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/CommandManager.js

@@ -14,224 +14,222 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['./snippets/TopHundredSnippets'], function(topHundred) {
-  let CommandManager
-  class Parser {
-    static initClass() {
-      // Ignore single letter commands since auto complete is moot then.
-      this.prototype.commandRegex = /\\([a-zA-Z]{2,})/
-    }
-    constructor(doc, prefix) {
-      this.doc = doc
-      this.prefix = prefix
+import topHundred from './snippets/TopHundredSnippets'
+let CommandManager
+class Parser {
+  static initClass() {
+    // Ignore single letter commands since auto complete is moot then.
+    this.prototype.commandRegex = /\\([a-zA-Z]{2,})/
+  }
+  constructor(doc, prefix) {
+    this.doc = doc
+    this.prefix = prefix
+  }
+
+  parse() {
+    // Safari regex is super slow, freezes browser for minutes on end,
+    // hacky solution: limit iterations
+    let command
+    let limit = null
+    if (
+      __guard__(
+        typeof window !== 'undefined' && window !== null
+          ? window._ide
+          : undefined,
+        x => x.browserIsSafari
+      )
+    ) {
+      limit = 5000
     }
 
-    parse() {
-      // Safari regex is super slow, freezes browser for minutes on end,
-      // hacky solution: limit iterations
-      let command
-      let limit = null
-      if (
-        __guard__(
-          typeof window !== 'undefined' && window !== null
-            ? window._ide
-            : undefined,
-          x => x.browserIsSafari
-        )
-      ) {
-        limit = 5000
+    // fully formed commands
+    const realCommands = []
+    // commands which match the prefix exactly,
+    // and could be partially typed or malformed
+    const incidentalCommands = []
+    const seen = {}
+    let iterations = 0
+    while ((command = this.nextCommand())) {
+      iterations += 1
+      if (limit && iterations > limit) {
+        return realCommands
       }
 
-      // fully formed commands
-      const realCommands = []
-      // commands which match the prefix exactly,
-      // and could be partially typed or malformed
-      const incidentalCommands = []
-      const seen = {}
-      let iterations = 0
-      while ((command = this.nextCommand())) {
-        iterations += 1
-        if (limit && iterations > limit) {
-          return realCommands
-        }
-
-        const docState = this.doc
+      const docState = this.doc
 
-        let optionalArgs = 0
-        while (this.consumeArgument('[', ']')) {
-          optionalArgs++
-        }
+      let optionalArgs = 0
+      while (this.consumeArgument('[', ']')) {
+        optionalArgs++
+      }
 
-        let args = 0
-        while (this.consumeArgument('{', '}')) {
-          args++
-        }
+      let args = 0
+      while (this.consumeArgument('{', '}')) {
+        args++
+      }
 
-        const commandHash = `${command}\\${optionalArgs}\\${args}`
+      const commandHash = `${command}\\${optionalArgs}\\${args}`
 
-        if (this.prefix != null && `\\${command}` === this.prefix) {
-          incidentalCommands.push([command, optionalArgs, args])
-        } else {
-          if (seen[commandHash] == null) {
-            seen[commandHash] = true
-            realCommands.push([command, optionalArgs, args])
-          }
+      if (this.prefix != null && `\\${command}` === this.prefix) {
+        incidentalCommands.push([command, optionalArgs, args])
+      } else {
+        if (seen[commandHash] == null) {
+          seen[commandHash] = true
+          realCommands.push([command, optionalArgs, args])
         }
-
-        // Reset to before argument to handle nested commands
-        this.doc = docState
-      }
-
-      // check incidentals, see if we should pluck out a match
-      if (incidentalCommands.length > 1) {
-        const bestMatch = incidentalCommands.sort(
-          (a, b) => a[1] + a[2] < b[1] + b[2]
-        )[0]
-        realCommands.push(bestMatch)
       }
 
-      return realCommands
+      // Reset to before argument to handle nested commands
+      this.doc = docState
     }
 
-    nextCommand() {
-      const i = this.doc.search(this.commandRegex)
-      if (i === -1) {
-        return false
-      } else {
-        const match = this.doc.match(this.commandRegex)[1]
-        this.doc = this.doc.substr(i + match.length + 1)
-        return match
-      }
+    // check incidentals, see if we should pluck out a match
+    if (incidentalCommands.length > 1) {
+      const bestMatch = incidentalCommands.sort(
+        (a, b) => a[1] + a[2] < b[1] + b[2]
+      )[0]
+      realCommands.push(bestMatch)
     }
 
-    consumeWhitespace() {
-      const match = this.doc.match(/^[ \t\n]*/m)[0]
-      return (this.doc = this.doc.substr(match.length))
+    return realCommands
+  }
+
+  nextCommand() {
+    const i = this.doc.search(this.commandRegex)
+    if (i === -1) {
+      return false
+    } else {
+      const match = this.doc.match(this.commandRegex)[1]
+      this.doc = this.doc.substr(i + match.length + 1)
+      return match
     }
+  }
 
-    consumeArgument(openingBracket, closingBracket) {
-      this.consumeWhitespace()
-
-      if (this.doc[0] === openingBracket) {
-        let i = 1
-        let bracketParity = 1
-        while (bracketParity > 0 && i < this.doc.length) {
-          if (this.doc[i] === openingBracket) {
-            bracketParity++
-          } else if (this.doc[i] === closingBracket) {
-            bracketParity--
-          }
-          i++
-        }
+  consumeWhitespace() {
+    const match = this.doc.match(/^[ \t\n]*/m)[0]
+    return (this.doc = this.doc.substr(match.length))
+  }
 
-        if (bracketParity === 0) {
-          this.doc = this.doc.substr(i)
-          return true
-        } else {
-          return false
+  consumeArgument(openingBracket, closingBracket) {
+    this.consumeWhitespace()
+
+    if (this.doc[0] === openingBracket) {
+      let i = 1
+      let bracketParity = 1
+      while (bracketParity > 0 && i < this.doc.length) {
+        if (this.doc[i] === openingBracket) {
+          bracketParity++
+        } else if (this.doc[i] === closingBracket) {
+          bracketParity--
         }
+        i++
+      }
+
+      if (bracketParity === 0) {
+        this.doc = this.doc.substr(i)
+        return true
       } else {
         return false
       }
+    } else {
+      return false
     }
   }
-  Parser.initClass()
+}
+Parser.initClass()
+
+export default (CommandManager = class CommandManager {
+  constructor(metadataManager) {
+    this.metadataManager = metadataManager
+  }
 
-  return (CommandManager = class CommandManager {
-    constructor(metadataManager) {
-      this.metadataManager = metadataManager
+  getCompletions(editor, session, pos, prefix, callback) {
+    const commandNames = {}
+    for (var snippet of Array.from(topHundred)) {
+      commandNames[snippet.caption.match(/\w+/)[0]] = true
     }
 
-    getCompletions(editor, session, pos, prefix, callback) {
-      const commandNames = {}
-      for (var snippet of Array.from(topHundred)) {
+    const packages = this.metadataManager.getAllPackages()
+    const packageCommands = []
+    for (let pkg in packages) {
+      const snippets = packages[pkg]
+      for (snippet of Array.from(snippets)) {
+        packageCommands.push(snippet)
         commandNames[snippet.caption.match(/\w+/)[0]] = true
       }
+    }
 
-      const packages = this.metadataManager.getAllPackages()
-      const packageCommands = []
-      for (let pkg in packages) {
-        const snippets = packages[pkg]
-        for (snippet of Array.from(snippets)) {
-          packageCommands.push(snippet)
-          commandNames[snippet.caption.match(/\w+/)[0]] = true
-        }
-      }
-
-      const doc = session.getValue()
-      const parser = new Parser(doc, prefix)
-      const commands = parser.parse()
-      let completions = []
-      for (let command of Array.from(commands)) {
-        if (!commandNames[command[0]]) {
-          let caption = `\\${command[0]}`
-          const score = caption === prefix ? 99 : 50
-          snippet = caption
-          var i = 1
-          _.times(command[1], function() {
-            snippet += `[\${${i}}]`
-            caption += '[]'
-            return i++
-          })
-          _.times(command[2], function() {
-            snippet += `{\${${i}}}`
-            caption += '{}'
-            return i++
-          })
-          completions.push({
-            caption,
-            snippet,
-            meta: 'cmd',
-            score
-          })
-        }
+    const doc = session.getValue()
+    const parser = new Parser(doc, prefix)
+    const commands = parser.parse()
+    let completions = []
+    for (let command of Array.from(commands)) {
+      if (!commandNames[command[0]]) {
+        let caption = `\\${command[0]}`
+        const score = caption === prefix ? 99 : 50
+        snippet = caption
+        var i = 1
+        _.times(command[1], function() {
+          snippet += `[\${${i}}]`
+          caption += '[]'
+          return i++
+        })
+        _.times(command[2], function() {
+          snippet += `{\${${i}}}`
+          caption += '{}'
+          return i++
+        })
+        completions.push({
+          caption,
+          snippet,
+          meta: 'cmd',
+          score
+        })
       }
-      completions = completions.concat(topHundred, packageCommands)
-
-      return callback(null, completions)
     }
+    completions = completions.concat(topHundred, packageCommands)
 
-    loadCommandsFromDoc(doc) {
-      const parser = new Parser(doc)
-      return (this.commands = parser.parse())
-    }
+    return callback(null, completions)
+  }
 
-    getSuggestions(commandFragment) {
-      const matchingCommands = _.filter(
-        this.commands,
-        command =>
-          command[0].slice(0, commandFragment.length) === commandFragment
-      )
+  loadCommandsFromDoc(doc) {
+    const parser = new Parser(doc)
+    return (this.commands = parser.parse())
+  }
 
-      return _.map(matchingCommands, function(command) {
-        let completionAfterCursor, completionBeforeCursor
-        const base = `\\${commandFragment}`
-
-        let args = ''
-        _.times(command[1], () => (args = args + '[]'))
-        _.times(command[2], () => (args = args + '{}'))
-        const completionBase = command[0].slice(commandFragment.length)
-
-        const squareArgsNo = command[1]
-        const curlyArgsNo = command[2]
-        const totalArgs = squareArgsNo + curlyArgsNo
-        if (totalArgs === 0) {
-          completionBeforeCursor = completionBase
-          completionAfterCursor = ''
-        } else {
-          completionBeforeCursor = completionBase + args[0]
-          completionAfterCursor = args.slice(1)
-        }
+  getSuggestions(commandFragment) {
+    const matchingCommands = _.filter(
+      this.commands,
+      command => command[0].slice(0, commandFragment.length) === commandFragment
+    )
+
+    return _.map(matchingCommands, function(command) {
+      let completionAfterCursor, completionBeforeCursor
+      const base = `\\${commandFragment}`
+
+      let args = ''
+      _.times(command[1], () => (args = args + '[]'))
+      _.times(command[2], () => (args = args + '{}'))
+      const completionBase = command[0].slice(commandFragment.length)
+
+      const squareArgsNo = command[1]
+      const curlyArgsNo = command[2]
+      const totalArgs = squareArgsNo + curlyArgsNo
+      if (totalArgs === 0) {
+        completionBeforeCursor = completionBase
+        completionAfterCursor = ''
+      } else {
+        completionBeforeCursor = completionBase + args[0]
+        completionAfterCursor = args.slice(1)
+      }
 
-        return {
-          base,
-          completion: completionBase + args,
-          completionBeforeCursor,
-          completionAfterCursor
-        }
-      })
-    }
-  })
+      return {
+        base,
+        completion: completionBase + args,
+        completionBeforeCursor,
+        completionAfterCursor
+      }
+    })
+  }
 })
 
 function __guard__(value, transform) {

+ 143 - 144
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/EnvironmentManager.js

@@ -9,31 +9,31 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['./snippets/Environments'], function(Environments) {
-  let staticSnippets = Array.from(Environments.withoutSnippets).map(env => ({
-    caption: `\\begin{${env}}...`,
-    snippet: `\
+import Environments from './snippets/Environments'
+let staticSnippets = Array.from(Environments.withoutSnippets).map(env => ({
+  caption: `\\begin{${env}}...`,
+  snippet: `\
 \\begin{${env}}
 \t$1
 \\end{${env}}\
 `,
-    meta: 'env'
-  }))
+  meta: 'env'
+}))
 
-  staticSnippets = staticSnippets.concat([
-    {
-      caption: '\\begin{array}...',
-      snippet: `\
+staticSnippets = staticSnippets.concat([
+  {
+    caption: '\\begin{array}...',
+    snippet: `\
 \\begin{array}{\${1:cc}}
 \t$2 & $3 \\\\\\\\
 \t$4 & $5
 \\end{array}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{figure}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{figure}...',
+    snippet: `\
 \\begin{figure}
 \t\\centering
 \t\\includegraphics{$1}
@@ -41,21 +41,21 @@ define(['./snippets/Environments'], function(Environments) {
 \t\\label{\${3:fig:my_label}}
 \\end{figure}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{tabular}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{tabular}...',
+    snippet: `\
 \\begin{tabular}{\${1:c|c}}
 \t$2 & $3 \\\\\\\\
 \t$4 & $5
 \\end{tabular}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{table}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{table}...',
+    snippet: `\
 \\begin{table}[$1]
 \t\\centering
 \t\\begin{tabular}{\${2:c|c}}
@@ -66,170 +66,169 @@ define(['./snippets/Environments'], function(Environments) {
 \t\\label{\${8:tab:my_label}}
 \\end{table}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{list}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{list}...',
+    snippet: `\
 \\begin{list}
 \t\\item $1
 \\end{list}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{enumerate}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{enumerate}...',
+    snippet: `\
 \\begin{enumerate}
 \t\\item $1
 \\end{enumerate}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{itemize}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{itemize}...',
+    snippet: `\
 \\begin{itemize}
 \t\\item $1
 \\end{itemize}\
 `,
-      meta: 'env'
-    },
-    {
-      caption: '\\begin{frame}...',
-      snippet: `\
+    meta: 'env'
+  },
+  {
+    caption: '\\begin{frame}...',
+    snippet: `\
 \\begin{frame}{\${1:Frame Title}}
 \t$2
 \\end{frame}\
 `,
-      meta: 'env'
-    }
-  ])
+    meta: 'env'
+  }
+])
 
-  const documentSnippet = {
-    caption: '\\begin{document}...',
-    snippet: `\
+const documentSnippet = {
+  caption: '\\begin{document}...',
+  snippet: `\
 \\begin{document}
 $1
 \\end{document}\
 `,
-    meta: 'env'
-  }
+  meta: 'env'
+}
 
-  const bibliographySnippet = {
-    caption: '\\begin{thebibliography}...',
-    snippet: `\
+const bibliographySnippet = {
+  caption: '\\begin{thebibliography}...',
+  snippet: `\
 \\begin{thebibliography}{$1}
 \\bibitem{$2}
 $3
 \\end{thebibliography}\
 `,
-    meta: 'env'
+  meta: 'env'
+}
+staticSnippets.push(documentSnippet)
+
+const parseCustomEnvironments = function(text) {
+  let match
+  const re = /^\\newenvironment{(\w+)}.*$/gm
+  const result = []
+  let iterations = 0
+  while ((match = re.exec(text))) {
+    result.push({ name: match[1], whitespace: null })
+    iterations += 1
+    if (iterations >= 1000) {
+      return result
+    }
   }
-  staticSnippets.push(documentSnippet)
+  return result
+}
 
-  const parseCustomEnvironments = function(text) {
-    let match
-    const re = /^\\newenvironment{(\w+)}.*$/gm
-    const result = []
-    let iterations = 0
-    while ((match = re.exec(text))) {
-      result.push({ name: match[1], whitespace: null })
+const parseBeginCommands = function(text) {
+  let match
+  const re = /^\\begin{(\w+)}.*\n([\t ]*).*$/gm
+  const result = []
+  let iterations = 0
+  while ((match = re.exec(text))) {
+    if (
+      !Array.from(Environments.all).includes(match[1]) &&
+      match[1] !== 'document'
+    ) {
+      result.push({ name: match[1], whitespace: match[2] })
       iterations += 1
       if (iterations >= 1000) {
         return result
       }
     }
-    return result
-  }
-
-  const parseBeginCommands = function(text) {
-    let match
-    const re = /^\\begin{(\w+)}.*\n([\t ]*).*$/gm
-    const result = []
-    let iterations = 0
-    while ((match = re.exec(text))) {
-      if (
-        !Array.from(Environments.all).includes(match[1]) &&
-        match[1] !== 'document'
-      ) {
-        result.push({ name: match[1], whitespace: match[2] })
-        iterations += 1
-        if (iterations >= 1000) {
-          return result
-        }
-      }
-    }
-    return result
   }
+  return result
+}
 
-  const hasDocumentEnvironment = function(text) {
-    const re = /^\\begin{document}/m
-    return re.exec(text) !== null
-  }
+const hasDocumentEnvironment = function(text) {
+  const re = /^\\begin{document}/m
+  return re.exec(text) !== null
+}
 
-  const hasBibliographyEnvironment = function(text) {
-    const re = /^\\begin{thebibliography}/m
-    return re.exec(text) !== null
-  }
+const hasBibliographyEnvironment = function(text) {
+  const re = /^\\begin{thebibliography}/m
+  return re.exec(text) !== null
+}
 
-  class EnvironmentManager {
-    getCompletions(editor, session, pos, prefix, callback) {
-      let ind
-      const docText = session.getValue()
-      const customEnvironments = parseCustomEnvironments(docText)
-      const beginCommands = parseBeginCommands(docText)
-      if (hasDocumentEnvironment(docText)) {
-        ind = staticSnippets.indexOf(documentSnippet)
-        if (ind !== -1) {
-          staticSnippets.splice(ind, 1)
-        }
-      } else {
-        staticSnippets.push(documentSnippet)
+class EnvironmentManager {
+  getCompletions(editor, session, pos, prefix, callback) {
+    let ind
+    const docText = session.getValue()
+    const customEnvironments = parseCustomEnvironments(docText)
+    const beginCommands = parseBeginCommands(docText)
+    if (hasDocumentEnvironment(docText)) {
+      ind = staticSnippets.indexOf(documentSnippet)
+      if (ind !== -1) {
+        staticSnippets.splice(ind, 1)
       }
+    } else {
+      staticSnippets.push(documentSnippet)
+    }
 
-      if (hasBibliographyEnvironment(docText)) {
-        ind = staticSnippets.indexOf(bibliographySnippet)
-        if (ind !== -1) {
-          staticSnippets.splice(ind, 1)
-        }
-      } else {
-        staticSnippets.push(bibliographySnippet)
+    if (hasBibliographyEnvironment(docText)) {
+      ind = staticSnippets.indexOf(bibliographySnippet)
+      if (ind !== -1) {
+        staticSnippets.splice(ind, 1)
       }
+    } else {
+      staticSnippets.push(bibliographySnippet)
+    }
 
-      const parsedItemsMap = {}
-      for (let environment of Array.from(customEnvironments)) {
-        parsedItemsMap[environment.name] = environment
-      }
-      for (let command of Array.from(beginCommands)) {
-        parsedItemsMap[command.name] = command
-      }
-      const parsedItems = _.values(parsedItemsMap)
-      const snippets = staticSnippets
-        .concat(
-          parsedItems.map(item => ({
-            caption: `\\begin{${item.name}}...`,
-            snippet: `\
+    const parsedItemsMap = {}
+    for (let environment of Array.from(customEnvironments)) {
+      parsedItemsMap[environment.name] = environment
+    }
+    for (let command of Array.from(beginCommands)) {
+      parsedItemsMap[command.name] = command
+    }
+    const parsedItems = _.values(parsedItemsMap)
+    const snippets = staticSnippets
+      .concat(
+        parsedItems.map(item => ({
+          caption: `\\begin{${item.name}}...`,
+          snippet: `\
 \\begin{${item.name}}
 ${item.whitespace || ''}$0
 \\end{${item.name}}\
 `,
-            meta: 'env'
-          }))
-        )
-        .concat(
-          // arguably these `end` commands shouldn't be here, as they're not snippets
-          // but this is where we have access to the `begin` environment names
-          // *shrug*
-          parsedItems.map(item => ({
-            caption: `\\end{${item.name}}`,
-            value: `\\end{${item.name}}`,
-            meta: 'env'
-          }))
-        )
-      return callback(null, snippets)
-    }
+          meta: 'env'
+        }))
+      )
+      .concat(
+        // arguably these `end` commands shouldn't be here, as they're not snippets
+        // but this is where we have access to the `begin` environment names
+        // *shrug*
+        parsedItems.map(item => ({
+          caption: `\\end{${item.name}}`,
+          value: `\\end{${item.name}}`,
+          meta: 'env'
+        }))
+      )
+    return callback(null, snippets)
   }
+}
 
-  return EnvironmentManager
-})
+export default EnvironmentManager

+ 47 - 51
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/Helpers.js

@@ -11,64 +11,60 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['ace/ace', 'ace/ext-language_tools'], function() {
-  const { Range } = ace.require('ace/range')
+import 'ace/ace'
+import 'ace/ext-language_tools'
+const { Range } = ace.require('ace/range')
 
-  var Helpers = {
-    getLastCommandFragment(lineUpToCursor) {
-      let index
-      if ((index = Helpers.getLastCommandFragmentIndex(lineUpToCursor)) > -1) {
-        return lineUpToCursor.slice(index)
-      } else {
-        return null
-      }
-    },
+var Helpers = {
+  getLastCommandFragment(lineUpToCursor) {
+    let index
+    if ((index = Helpers.getLastCommandFragmentIndex(lineUpToCursor)) > -1) {
+      return lineUpToCursor.slice(index)
+    } else {
+      return null
+    }
+  },
 
-    getLastCommandFragmentIndex(lineUpToCursor) {
-      // This is hack to let us skip over commands in arguments, and
-      // go to the command on the same 'level' as us. E.g.
-      //    \includegraphics[width=\textwidth]{..
-      // should not match the \textwidth.
-      let m
-      const blankArguments = lineUpToCursor.replace(/\[([^\]]*)\]/g, args =>
-        Array(args.length + 1).join('.')
-      )
-      if ((m = blankArguments.match(/(\\[^\\]*)$/))) {
-        return m.index
-      } else {
-        return -1
-      }
-    },
+  getLastCommandFragmentIndex(lineUpToCursor) {
+    // This is hack to let us skip over commands in arguments, and
+    // go to the command on the same 'level' as us. E.g.
+    //    \includegraphics[width=\textwidth]{..
+    // should not match the \textwidth.
+    let m
+    const blankArguments = lineUpToCursor.replace(/\[([^\]]*)\]/g, args =>
+      Array(args.length + 1).join('.')
+    )
+    if ((m = blankArguments.match(/(\\[^\\]*)$/))) {
+      return m.index
+    } else {
+      return -1
+    }
+  },
 
-    getCommandNameFromFragment(commandFragment) {
-      return __guard__(
-        commandFragment != null
-          ? commandFragment.match(/\\(\w+)\{/)
-          : undefined,
-        x => x[1]
-      )
-    },
+  getCommandNameFromFragment(commandFragment) {
+    return __guard__(
+      commandFragment != null ? commandFragment.match(/\\(\w+)\{/) : undefined,
+      x => x[1]
+    )
+  },
 
-    getContext(editor, pos) {
-      const upToCursorRange = new Range(pos.row, 0, pos.row, pos.column)
-      const lineUpToCursor = editor.getSession().getTextRange(upToCursorRange)
-      const commandFragment = Helpers.getLastCommandFragment(lineUpToCursor)
-      const commandName = Helpers.getCommandNameFromFragment(commandFragment)
-      const beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999)
-      const lineBeyondCursor = editor
-        .getSession()
-        .getTextRange(beyondCursorRange)
-      return {
-        lineUpToCursor,
-        commandFragment,
-        commandName,
-        lineBeyondCursor
-      }
+  getContext(editor, pos) {
+    const upToCursorRange = new Range(pos.row, 0, pos.row, pos.column)
+    const lineUpToCursor = editor.getSession().getTextRange(upToCursorRange)
+    const commandFragment = Helpers.getLastCommandFragment(lineUpToCursor)
+    const commandName = Helpers.getCommandNameFromFragment(commandFragment)
+    const beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999)
+    const lineBeyondCursor = editor.getSession().getTextRange(beyondCursorRange)
+    return {
+      lineUpToCursor,
+      commandFragment,
+      commandName,
+      lineBeyondCursor
     }
   }
+}
 
-  return Helpers
-})
+export default Helpers
 
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null

+ 126 - 128
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/PackageManager.js

@@ -9,137 +9,135 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  const packages = [
-    'inputenc',
-    'graphicx',
-    'amsmath',
-    'geometry',
-    'amssymb',
-    'hyperref',
-    'babel',
-    'color',
-    'xcolor',
-    'url',
-    'natbib',
-    'fontenc',
-    'fancyhdr',
-    'amsfonts',
-    'booktabs',
-    'amsthm',
-    'float',
-    'tikz',
-    'caption',
-    'setspace',
-    'multirow',
-    'array',
-    'multicol',
-    'titlesec',
-    'enumitem',
-    'ifthen',
-    'listings',
-    'blindtext',
-    'subcaption',
-    'times',
-    'bm',
-    'subfigure',
-    'algorithm',
-    'fontspec',
-    'biblatex',
-    'tabularx',
-    'microtype',
-    'etoolbox',
-    'parskip',
-    'calc',
-    'verbatim',
-    'mathtools',
-    'epsfig',
-    'wrapfig',
-    'lipsum',
-    'cite',
-    'textcomp',
-    'longtable',
-    'textpos',
-    'algpseudocode',
-    'enumerate',
-    'subfig',
-    'pdfpages',
-    'epstopdf',
-    'latexsym',
-    'lmodern',
-    'pifont',
-    'ragged2e',
-    'rotating',
-    'dcolumn',
-    'xltxtra',
-    'marvosym',
-    'indentfirst',
-    'xspace',
-    'csquotes',
-    'xparse',
-    'changepage',
-    'soul',
-    'xunicode',
-    'comment',
-    'mathrsfs',
-    'tocbibind',
-    'lastpage',
-    'algorithm2e',
-    'pgfplots',
-    'lineno',
-    'graphics',
-    'algorithmic',
-    'fullpage',
-    'mathptmx',
-    'todonotes',
-    'ulem',
-    'tweaklist',
-    'moderncvstyleclassic',
-    'collection',
-    'moderncvcompatibility',
-    'gensymb',
-    'helvet',
-    'siunitx',
-    'adjustbox',
-    'placeins',
-    'colortbl',
-    'appendix',
-    'makeidx',
-    'supertabular',
-    'ifpdf',
-    'framed',
-    'aliascnt',
-    'layaureo',
-    'authblk'
-  ]
+const packages = [
+  'inputenc',
+  'graphicx',
+  'amsmath',
+  'geometry',
+  'amssymb',
+  'hyperref',
+  'babel',
+  'color',
+  'xcolor',
+  'url',
+  'natbib',
+  'fontenc',
+  'fancyhdr',
+  'amsfonts',
+  'booktabs',
+  'amsthm',
+  'float',
+  'tikz',
+  'caption',
+  'setspace',
+  'multirow',
+  'array',
+  'multicol',
+  'titlesec',
+  'enumitem',
+  'ifthen',
+  'listings',
+  'blindtext',
+  'subcaption',
+  'times',
+  'bm',
+  'subfigure',
+  'algorithm',
+  'fontspec',
+  'biblatex',
+  'tabularx',
+  'microtype',
+  'etoolbox',
+  'parskip',
+  'calc',
+  'verbatim',
+  'mathtools',
+  'epsfig',
+  'wrapfig',
+  'lipsum',
+  'cite',
+  'textcomp',
+  'longtable',
+  'textpos',
+  'algpseudocode',
+  'enumerate',
+  'subfig',
+  'pdfpages',
+  'epstopdf',
+  'latexsym',
+  'lmodern',
+  'pifont',
+  'ragged2e',
+  'rotating',
+  'dcolumn',
+  'xltxtra',
+  'marvosym',
+  'indentfirst',
+  'xspace',
+  'csquotes',
+  'xparse',
+  'changepage',
+  'soul',
+  'xunicode',
+  'comment',
+  'mathrsfs',
+  'tocbibind',
+  'lastpage',
+  'algorithm2e',
+  'pgfplots',
+  'lineno',
+  'graphics',
+  'algorithmic',
+  'fullpage',
+  'mathptmx',
+  'todonotes',
+  'ulem',
+  'tweaklist',
+  'moderncvstyleclassic',
+  'collection',
+  'moderncvcompatibility',
+  'gensymb',
+  'helvet',
+  'siunitx',
+  'adjustbox',
+  'placeins',
+  'colortbl',
+  'appendix',
+  'makeidx',
+  'supertabular',
+  'ifpdf',
+  'framed',
+  'aliascnt',
+  'layaureo',
+  'authblk'
+]
 
-  class PackageManager {
-    constructor(metadataManager) {
-      this.metadataManager = metadataManager
-    }
+class PackageManager {
+  constructor(metadataManager) {
+    this.metadataManager = metadataManager
+  }
 
-    getCompletions(editor, session, pos, prefix, callback) {
-      const usedPackages = Object.keys(this.metadataManager.getAllPackages())
-      const packageSnippets = []
-      for (let pkg of Array.from(packages)) {
-        if (!Array.from(usedPackages).includes(pkg)) {
-          packageSnippets.push({
-            caption: `\\usepackage{${pkg}}`,
-            snippet: `\\usepackage{${pkg}}`,
-            meta: 'pkg'
-          })
-        }
+  getCompletions(editor, session, pos, prefix, callback) {
+    const usedPackages = Object.keys(this.metadataManager.getAllPackages())
+    const packageSnippets = []
+    for (let pkg of Array.from(packages)) {
+      if (!Array.from(usedPackages).includes(pkg)) {
+        packageSnippets.push({
+          caption: `\\usepackage{${pkg}}`,
+          snippet: `\\usepackage{${pkg}}`,
+          meta: 'pkg'
+        })
       }
-
-      packageSnippets.push({
-        caption: '\\usepackage{}',
-        snippet: '\\usepackage{$1}',
-        meta: 'pkg',
-        score: 70
-      })
-      return callback(null, packageSnippets)
     }
+
+    packageSnippets.push({
+      caption: '\\usepackage{}',
+      snippet: '\\usepackage{$1}',
+      meta: 'pkg',
+      score: 70
+    })
+    return callback(null, packageSnippets)
   }
+}
 
-  return PackageManager
-})
+export default PackageManager

+ 30 - 32
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/snippets/Environments.js

@@ -2,37 +2,35 @@
 */
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
-define(function() {
-  const envs = [
-    'abstract',
-    'align',
-    'align*',
-    'equation',
-    'equation*',
-    'gather',
-    'gather*',
-    'multline',
-    'multline*',
-    'split',
-    'verbatim',
-    'quote',
-    'center'
-  ]
+const envs = [
+  'abstract',
+  'align',
+  'align*',
+  'equation',
+  'equation*',
+  'gather',
+  'gather*',
+  'multline',
+  'multline*',
+  'split',
+  'verbatim',
+  'quote',
+  'center'
+]
 
-  const envsWithSnippets = [
-    'array',
-    'figure',
-    'tabular',
-    'table',
-    'list',
-    'enumerate',
-    'itemize',
-    'frame',
-    'thebibliography'
-  ]
+const envsWithSnippets = [
+  'array',
+  'figure',
+  'tabular',
+  'table',
+  'list',
+  'enumerate',
+  'itemize',
+  'frame',
+  'thebibliography'
+]
 
-  return {
-    all: envs.concat(envsWithSnippets),
-    withoutSnippets: envs
-  }
-})
+export default {
+  all: envs.concat(envsWithSnippets),
+  withoutSnippets: envs
+}

+ 2 - 2
services/web/frontend/js/ide/editor/directives/aceEditor/auto-complete/snippets/TopHundredSnippets.js

@@ -7,7 +7,7 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(() => [
+export default [
   {
     caption: '\\begin{}',
     snippet: '\\begin{$1}',
@@ -836,4 +836,4 @@ define(() => [
     meta: 'cmd',
     score: 0.09490387997853639
   }
-])
+]

+ 44 - 47
services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter.js

@@ -9,51 +9,48 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../EditorShareJsCodec'], function(EditorShareJsCodec) {
-  let CursorPositionAdapter
-  return (CursorPositionAdapter = class CursorPositionAdapter {
-    constructor(editor) {
-      this.editor = editor
-    }
-
-    getCursor() {
-      return this.editor.getCursorPosition()
-    }
-
-    getEditorScrollPosition() {
-      return this.editor.getFirstVisibleRow()
-    }
-
-    setCursor(pos) {
-      pos = pos.cursorPosition || { row: 0, column: 0 }
-      return this.editor.moveCursorToPosition(pos)
-    }
-
-    setEditorScrollPosition(pos) {
-      pos = pos.firstVisibleLine || 0
-      return this.editor.scrollToLine(pos)
-    }
-
-    clearSelection() {
-      return this.editor.selection.clearSelection()
-    }
-
-    gotoLine(line, column) {
-      this.editor.gotoLine(line, column)
-      this.editor.scrollToLine(line, true, true) // centre and animate
-      return this.editor.focus()
-    }
-
-    gotoOffset(offset) {
-      const lines = this.editor
-        .getSession()
-        .getDocument()
-        .getAllLines()
-      const position = EditorShareJsCodec.shareJsOffsetToRowColumn(
-        offset,
-        lines
-      )
-      return this.gotoLine(position.row + 1, position.column)
-    }
-  })
+import EditorShareJsCodec from '../../../EditorShareJsCodec'
+let CursorPositionAdapter
+
+export default (CursorPositionAdapter = class CursorPositionAdapter {
+  constructor(editor) {
+    this.editor = editor
+  }
+
+  getCursor() {
+    return this.editor.getCursorPosition()
+  }
+
+  getEditorScrollPosition() {
+    return this.editor.getFirstVisibleRow()
+  }
+
+  setCursor(pos) {
+    pos = pos.cursorPosition || { row: 0, column: 0 }
+    return this.editor.moveCursorToPosition(pos)
+  }
+
+  setEditorScrollPosition(pos) {
+    pos = pos.firstVisibleLine || 0
+    return this.editor.scrollToLine(pos)
+  }
+
+  clearSelection() {
+    return this.editor.selection.clearSelection()
+  }
+
+  gotoLine(line, column) {
+    this.editor.gotoLine(line, column)
+    this.editor.scrollToLine(line, true, true) // centre and animate
+    return this.editor.focus()
+  }
+
+  gotoOffset(offset) {
+    const lines = this.editor
+      .getSession()
+      .getDocument()
+      .getAllLines()
+    const position = EditorShareJsCodec.shareJsOffsetToRowColumn(offset, lines)
+    return this.gotoLine(position.row + 1, position.column)
+  }
 })

+ 83 - 88
services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js

@@ -10,105 +10,100 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  let CursorPositionManager
-  return (CursorPositionManager = class CursorPositionManager {
-    constructor($scope, adapter, localStorage) {
-      this.storePositionAndLine = this.storePositionAndLine.bind(this)
-      this.jumpToPositionInNewDoc = this.jumpToPositionInNewDoc.bind(this)
-      this.onUnload = this.onUnload.bind(this)
-      this.onCursorChange = this.onCursorChange.bind(this)
-      this.onSyncToPdf = this.onSyncToPdf.bind(this)
-      this.$scope = $scope
-      this.adapter = adapter
-      this.localStorage = localStorage
-      this.$scope.$on('editorInit', this.jumpToPositionInNewDoc)
-
-      this.$scope.$on('beforeChangeDocument', this.storePositionAndLine)
-
-      this.$scope.$on('afterChangeDocument', this.jumpToPositionInNewDoc)
-
-      this.$scope.$on('changeEditor', this.storePositionAndLine)
-
-      this.$scope.$on(`${this.$scope.name}:gotoLine`, (e, line, column) => {
-        if (line != null) {
-          return setTimeout(() => {
-            return this.adapter.gotoLine(line, column)
-          }, 10)
-        }
-      }) // Hack: Must happen after @gotoStoredPosition
-
-      this.$scope.$on(`${this.$scope.name}:gotoOffset`, (e, offset) => {
-        if (offset != null) {
-          return setTimeout(() => {
-            return this.adapter.gotoOffset(offset)
-          }, 10)
-        }
-      }) // Hack: Must happen after @gotoStoredPosition
-
-      this.$scope.$on(`${this.$scope.name}:clearSelection`, e => {
-        return this.adapter.clearSelection()
-      })
-    }
+let CursorPositionManager
 
-    storePositionAndLine() {
-      this.storeCursorPosition()
-      return this.storeFirstVisibleLine()
-    }
+export default (CursorPositionManager = class CursorPositionManager {
+  constructor($scope, adapter, localStorage) {
+    this.storePositionAndLine = this.storePositionAndLine.bind(this)
+    this.jumpToPositionInNewDoc = this.jumpToPositionInNewDoc.bind(this)
+    this.onUnload = this.onUnload.bind(this)
+    this.onCursorChange = this.onCursorChange.bind(this)
+    this.onSyncToPdf = this.onSyncToPdf.bind(this)
+    this.$scope = $scope
+    this.adapter = adapter
+    this.localStorage = localStorage
+    this.$scope.$on('editorInit', this.jumpToPositionInNewDoc)
 
-    jumpToPositionInNewDoc() {
-      this.doc_id =
-        this.$scope.sharejsDoc != null
-          ? this.$scope.sharejsDoc.doc_id
-          : undefined
-      return setTimeout(() => {
-        return this.gotoStoredPosition()
-      }, 0)
-    }
+    this.$scope.$on('beforeChangeDocument', this.storePositionAndLine)
 
-    onUnload() {
-      this.storeCursorPosition()
-      return this.storeFirstVisibleLine()
-    }
+    this.$scope.$on('afterChangeDocument', this.jumpToPositionInNewDoc)
 
-    onCursorChange() {
-      return this.emitCursorUpdateEvent()
-    }
+    this.$scope.$on('changeEditor', this.storePositionAndLine)
 
-    onSyncToPdf() {
-      return this.$scope.$emit(`cursor:${this.$scope.name}:syncToPdf`)
-    }
-
-    storeFirstVisibleLine() {
-      if (this.doc_id != null) {
-        const docPosition =
-          this.localStorage(`doc.position.${this.doc_id}`) || {}
-        docPosition.firstVisibleLine = this.adapter.getEditorScrollPosition()
-        return this.localStorage(`doc.position.${this.doc_id}`, docPosition)
+    this.$scope.$on(`${this.$scope.name}:gotoLine`, (e, line, column) => {
+      if (line != null) {
+        return setTimeout(() => {
+          return this.adapter.gotoLine(line, column)
+        }, 10)
       }
-    }
+    }) // Hack: Must happen after @gotoStoredPosition
 
-    storeCursorPosition() {
-      if (this.doc_id != null) {
-        const docPosition =
-          this.localStorage(`doc.position.${this.doc_id}`) || {}
-        docPosition.cursorPosition = this.adapter.getCursor()
-        return this.localStorage(`doc.position.${this.doc_id}`, docPosition)
+    this.$scope.$on(`${this.$scope.name}:gotoOffset`, (e, offset) => {
+      if (offset != null) {
+        return setTimeout(() => {
+          return this.adapter.gotoOffset(offset)
+        }, 10)
       }
+    }) // Hack: Must happen after @gotoStoredPosition
+
+    this.$scope.$on(`${this.$scope.name}:clearSelection`, e => {
+      return this.adapter.clearSelection()
+    })
+  }
+
+  storePositionAndLine() {
+    this.storeCursorPosition()
+    return this.storeFirstVisibleLine()
+  }
+
+  jumpToPositionInNewDoc() {
+    this.doc_id =
+      this.$scope.sharejsDoc != null ? this.$scope.sharejsDoc.doc_id : undefined
+    return setTimeout(() => {
+      return this.gotoStoredPosition()
+    }, 0)
+  }
+
+  onUnload() {
+    this.storeCursorPosition()
+    return this.storeFirstVisibleLine()
+  }
+
+  onCursorChange() {
+    return this.emitCursorUpdateEvent()
+  }
+
+  onSyncToPdf() {
+    return this.$scope.$emit(`cursor:${this.$scope.name}:syncToPdf`)
+  }
+
+  storeFirstVisibleLine() {
+    if (this.doc_id != null) {
+      const docPosition = this.localStorage(`doc.position.${this.doc_id}`) || {}
+      docPosition.firstVisibleLine = this.adapter.getEditorScrollPosition()
+      return this.localStorage(`doc.position.${this.doc_id}`, docPosition)
     }
+  }
 
-    emitCursorUpdateEvent() {
-      const cursor = this.adapter.getCursor()
-      return this.$scope.$emit(`cursor:${this.$scope.name}:update`, cursor)
+  storeCursorPosition() {
+    if (this.doc_id != null) {
+      const docPosition = this.localStorage(`doc.position.${this.doc_id}`) || {}
+      docPosition.cursorPosition = this.adapter.getCursor()
+      return this.localStorage(`doc.position.${this.doc_id}`, docPosition)
     }
+  }
 
-    gotoStoredPosition() {
-      if (this.doc_id == null) {
-        return
-      }
-      const pos = this.localStorage(`doc.position.${this.doc_id}`) || {}
-      this.adapter.setCursor(pos)
-      return this.adapter.setEditorScrollPosition(pos)
+  emitCursorUpdateEvent() {
+    const cursor = this.adapter.getCursor()
+    return this.$scope.$emit(`cursor:${this.$scope.name}:update`, cursor)
+  }
+
+  gotoStoredPosition() {
+    if (this.doc_id == null) {
+      return
     }
-  })
+    const pos = this.localStorage(`doc.position.${this.doc_id}`) || {}
+    this.adapter.setCursor(pos)
+    return this.adapter.setEditorScrollPosition(pos)
+  }
 })

+ 331 - 340
services/web/frontend/js/ide/editor/directives/aceEditor/highlights/HighlightsManager.js

@@ -15,387 +15,378 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['ace/ace', '../../../../colors/ColorManager'], function(
-  _,
-  ColorManager
-) {
-  let HighlightsManager
-  const { Range } = ace.require('ace/range')
-
-  return (HighlightsManager = class HighlightsManager {
-    constructor($scope, editor, element) {
-      this.$scope = $scope
-      this.editor = editor
-      this.element = element
-      this.markerIds = []
-      this.labels = []
-
-      this.$scope.annotationLabel = {
-        show: false,
-        right: 'auto',
-        left: 'auto',
-        top: 'auto',
-        bottom: 'auto',
-        backgroundColor: 'black',
-        text: ''
-      }
-
-      this.$scope.updateLabels = {
-        updatesAbove: 0,
-        updatesBelow: 0
-      }
+import _ from 'ace/ace'
+import ColorManager from '../../../../colors/ColorManager'
+let HighlightsManager
+const { Range } = ace.require('ace/range')
+
+export default (HighlightsManager = class HighlightsManager {
+  constructor($scope, editor, element) {
+    this.$scope = $scope
+    this.editor = editor
+    this.element = element
+    this.markerIds = []
+    this.labels = []
+
+    this.$scope.annotationLabel = {
+      show: false,
+      right: 'auto',
+      left: 'auto',
+      top: 'auto',
+      bottom: 'auto',
+      backgroundColor: 'black',
+      text: ''
+    }
 
-      this.$scope.$watch('highlights', value => {
-        return this.redrawAnnotations()
-      })
+    this.$scope.updateLabels = {
+      updatesAbove: 0,
+      updatesBelow: 0
+    }
 
-      this.$scope.$watch('theme', value => {
-        return this.redrawAnnotations()
-      })
+    this.$scope.$watch('highlights', value => {
+      return this.redrawAnnotations()
+    })
 
-      this.editor.on('mousemove', e => {
-        const position = this.editor.renderer.screenToTextCoordinates(
-          e.clientX,
-          e.clientY
-        )
-        e.position = position
-        return this.showAnnotationLabels(position)
-      })
+    this.$scope.$watch('theme', value => {
+      return this.redrawAnnotations()
+    })
 
-      const onChangeScrollTop = () => {
-        return this.updateShowMoreLabels()
-      }
+    this.editor.on('mousemove', e => {
+      const position = this.editor.renderer.screenToTextCoordinates(
+        e.clientX,
+        e.clientY
+      )
+      e.position = position
+      return this.showAnnotationLabels(position)
+    })
 
-      this.editor.getSession().on('changeScrollTop', onChangeScrollTop)
+    const onChangeScrollTop = () => {
+      return this.updateShowMoreLabels()
+    }
 
-      this.$scope.$watch('text', () => {
-        if (this.$scope.navigateHighlights) {
-          return setTimeout(() => {
-            return this.scrollToFirstHighlight()
-          }, 0)
-        }
-      })
+    this.editor.getSession().on('changeScrollTop', onChangeScrollTop)
 
-      this.editor.on('changeSession', e => {
-        if (e.oldSession != null) {
-          e.oldSession.off('changeScrollTop', onChangeScrollTop)
-        }
-        e.session.on('changeScrollTop', onChangeScrollTop)
-        return this.redrawAnnotations()
-      })
+    this.$scope.$watch('text', () => {
+      if (this.$scope.navigateHighlights) {
+        return setTimeout(() => {
+          return this.scrollToFirstHighlight()
+        }, 0)
+      }
+    })
 
-      this.$scope.gotoHighlightBelow = () => {
-        if (this.firstHiddenHighlightAfter == null) {
-          return
-        }
-        return this.editor.scrollToLine(
-          this.firstHiddenHighlightAfter.end.row,
-          true,
-          false
-        )
+    this.editor.on('changeSession', e => {
+      if (e.oldSession != null) {
+        e.oldSession.off('changeScrollTop', onChangeScrollTop)
       }
+      e.session.on('changeScrollTop', onChangeScrollTop)
+      return this.redrawAnnotations()
+    })
 
-      this.$scope.gotoHighlightAbove = () => {
-        if (this.lastHiddenHighlightBefore == null) {
-          return
-        }
-        return this.editor.scrollToLine(
-          this.lastHiddenHighlightBefore.start.row,
-          true,
-          false
-        )
+    this.$scope.gotoHighlightBelow = () => {
+      if (this.firstHiddenHighlightAfter == null) {
+        return
       }
+      return this.editor.scrollToLine(
+        this.firstHiddenHighlightAfter.end.row,
+        true,
+        false
+      )
     }
 
-    redrawAnnotations() {
-      this._clearMarkers()
-      this._clearLabels()
-
-      for (let annotation of Array.from(this.$scope.highlights || [])) {
-        ;(annotation => {
-          const colorScheme = ColorManager.getColorScheme(
-            annotation.hue,
-            this.element
-          )
-          if (annotation.cursor != null) {
-            this.labels.push({
-              text: annotation.label,
-              range: new Range(
-                annotation.cursor.row,
-                annotation.cursor.column,
-                annotation.cursor.row,
-                annotation.cursor.column + 1
-              ),
-              colorScheme,
-              snapToStartOfRange: true
-            })
-            return this._drawCursor(annotation, colorScheme)
-          } else if (annotation.highlight != null) {
-            this.labels.push({
-              text: annotation.label,
-              range: new Range(
-                annotation.highlight.start.row,
-                annotation.highlight.start.column,
-                annotation.highlight.end.row,
-                annotation.highlight.end.column
-              ),
-              colorScheme
-            })
-            return this._drawHighlight(annotation, colorScheme)
-          } else if (annotation.strikeThrough != null) {
-            this.labels.push({
-              text: annotation.label,
-              range: new Range(
-                annotation.strikeThrough.start.row,
-                annotation.strikeThrough.start.column,
-                annotation.strikeThrough.end.row,
-                annotation.strikeThrough.end.column
-              ),
-              colorScheme
-            })
-            return this._drawStrikeThrough(annotation, colorScheme)
-          }
-        })(annotation)
+    this.$scope.gotoHighlightAbove = () => {
+      if (this.lastHiddenHighlightBefore == null) {
+        return
       }
-
-      return this.updateShowMoreLabels()
+      return this.editor.scrollToLine(
+        this.lastHiddenHighlightBefore.start.row,
+        true,
+        false
+      )
     }
+  }
 
-    showAnnotationLabels(position) {
-      let labelToShow = null
-      for (let label of Array.from(this.labels || [])) {
-        if (label.range.contains(position.row, position.column)) {
-          labelToShow = label
-        }
-      }
+  redrawAnnotations() {
+    this._clearMarkers()
+    this._clearLabels()
 
-      if (labelToShow == null) {
-        // this is the most common path, triggered on mousemove, so
-        // for performance only apply setting when it changes
-        if (
-          __guard__(
-            this.$scope != null ? this.$scope.annotationLabel : undefined,
-            x => x.show
-          ) !== false
-        ) {
-          return this.$scope.$apply(() => {
-            return (this.$scope.annotationLabel.show = false)
+    for (let annotation of Array.from(this.$scope.highlights || [])) {
+      ;(annotation => {
+        const colorScheme = ColorManager.getColorScheme(
+          annotation.hue,
+          this.element
+        )
+        if (annotation.cursor != null) {
+          this.labels.push({
+            text: annotation.label,
+            range: new Range(
+              annotation.cursor.row,
+              annotation.cursor.column,
+              annotation.cursor.row,
+              annotation.cursor.column + 1
+            ),
+            colorScheme,
+            snapToStartOfRange: true
           })
+          return this._drawCursor(annotation, colorScheme)
+        } else if (annotation.highlight != null) {
+          this.labels.push({
+            text: annotation.label,
+            range: new Range(
+              annotation.highlight.start.row,
+              annotation.highlight.start.column,
+              annotation.highlight.end.row,
+              annotation.highlight.end.column
+            ),
+            colorScheme
+          })
+          return this._drawHighlight(annotation, colorScheme)
+        } else if (annotation.strikeThrough != null) {
+          this.labels.push({
+            text: annotation.label,
+            range: new Range(
+              annotation.strikeThrough.start.row,
+              annotation.strikeThrough.start.column,
+              annotation.strikeThrough.end.row,
+              annotation.strikeThrough.end.column
+            ),
+            colorScheme
+          })
+          return this._drawStrikeThrough(annotation, colorScheme)
         }
-      } else {
-        let bottom, coords, left, right, top
-        const $ace = $(this.editor.renderer.container).find('.ace_scroller')
-        // Move the label into the Ace content area so that offsets and positions are easy to calculate.
-        $ace.append(this.element.find('.annotation-label'))
-
-        if (labelToShow.snapToStartOfRange) {
-          coords = this.editor.renderer.textToScreenCoordinates(
-            labelToShow.range.start.row,
-            labelToShow.range.start.column
-          )
-        } else {
-          coords = this.editor.renderer.textToScreenCoordinates(
-            position.row,
-            position.column
-          )
-        }
-
-        const offset = $ace.offset()
-        const height = $ace.height()
-        coords.pageX = coords.pageX - offset.left
-        coords.pageY = coords.pageY - offset.top
-
-        if (coords.pageY > this.editor.renderer.lineHeight * 2) {
-          top = 'auto'
-          bottom = height - coords.pageY
-        } else {
-          top = coords.pageY + this.editor.renderer.lineHeight
-          bottom = 'auto'
-        }
-
-        // Apply this first that the label has the correct width when calculating below
-        this.$scope.$apply(() => {
-          this.$scope.annotationLabel.text = labelToShow.text
-          return (this.$scope.annotationLabel.show = true)
-        })
+      })(annotation)
+    }
 
-        const $label = this.element.find('.annotation-label')
+    return this.updateShowMoreLabels()
+  }
 
-        if (coords.pageX + $label.outerWidth() < $ace.width()) {
-          left = coords.pageX
-          right = 'auto'
-        } else {
-          right = 0
-          left = 'auto'
-        }
+  showAnnotationLabels(position) {
+    let labelToShow = null
+    for (let label of Array.from(this.labels || [])) {
+      if (label.range.contains(position.row, position.column)) {
+        labelToShow = label
+      }
+    }
 
+    if (labelToShow == null) {
+      // this is the most common path, triggered on mousemove, so
+      // for performance only apply setting when it changes
+      if (
+        __guard__(
+          this.$scope != null ? this.$scope.annotationLabel : undefined,
+          x => x.show
+        ) !== false
+      ) {
         return this.$scope.$apply(() => {
-          return (this.$scope.annotationLabel = {
-            show: true,
-            left,
-            right,
-            bottom,
-            top,
-            backgroundColor: labelToShow.colorScheme.labelBackgroundColor,
-            text: labelToShow.text
-          })
+          return (this.$scope.annotationLabel.show = false)
         })
       }
-    }
+    } else {
+      let bottom, coords, left, right, top
+      const $ace = $(this.editor.renderer.container).find('.ace_scroller')
+      // Move the label into the Ace content area so that offsets and positions are easy to calculate.
+      $ace.append(this.element.find('.annotation-label'))
+
+      if (labelToShow.snapToStartOfRange) {
+        coords = this.editor.renderer.textToScreenCoordinates(
+          labelToShow.range.start.row,
+          labelToShow.range.start.column
+        )
+      } else {
+        coords = this.editor.renderer.textToScreenCoordinates(
+          position.row,
+          position.column
+        )
+      }
 
-    updateShowMoreLabels() {
-      if (!this.$scope.navigateHighlights) {
-        return
+      const offset = $ace.offset()
+      const height = $ace.height()
+      coords.pageX = coords.pageX - offset.left
+      coords.pageY = coords.pageY - offset.top
+
+      if (coords.pageY > this.editor.renderer.lineHeight * 2) {
+        top = 'auto'
+        bottom = height - coords.pageY
+      } else {
+        top = coords.pageY + this.editor.renderer.lineHeight
+        bottom = 'auto'
       }
-      return setTimeout(() => {
-        const firstRow = this.editor.getFirstVisibleRow()
-        const lastRow = this.editor.getLastVisibleRow()
-        let highlightsBefore = 0
-        let highlightsAfter = 0
-        this.lastHiddenHighlightBefore = null
-        this.firstHiddenHighlightAfter = null
-        for (let annotation of Array.from(this.$scope.highlights || [])) {
-          const range = annotation.highlight || annotation.strikeThrough
-          if (range == null) {
-            continue
-          }
-          if (range.start.row < firstRow) {
-            highlightsBefore += 1
-            this.lastHiddenHighlightBefore = range
-          }
-          if (range.end.row > lastRow) {
-            highlightsAfter += 1
-            if (!this.firstHiddenHighlightAfter) {
-              this.firstHiddenHighlightAfter = range
-            }
-          }
-        }
 
-        return this.$scope.$apply(() => {
-          return (this.$scope.updateLabels = {
-            highlightsBefore,
-            highlightsAfter
-          })
+      // Apply this first that the label has the correct width when calculating below
+      this.$scope.$apply(() => {
+        this.$scope.annotationLabel.text = labelToShow.text
+        return (this.$scope.annotationLabel.show = true)
+      })
+
+      const $label = this.element.find('.annotation-label')
+
+      if (coords.pageX + $label.outerWidth() < $ace.width()) {
+        left = coords.pageX
+        right = 'auto'
+      } else {
+        right = 0
+        left = 'auto'
+      }
+
+      return this.$scope.$apply(() => {
+        return (this.$scope.annotationLabel = {
+          show: true,
+          left,
+          right,
+          bottom,
+          top,
+          backgroundColor: labelToShow.colorScheme.labelBackgroundColor,
+          text: labelToShow.text
         })
-      }, 100)
+      })
     }
+  }
 
-    scrollToFirstHighlight() {
-      return (() => {
-        const result = []
-        for (let annotation of Array.from(this.$scope.highlights || [])) {
-          const range = annotation.highlight || annotation.strikeThrough
-          if (range == null) {
-            continue
+  updateShowMoreLabels() {
+    if (!this.$scope.navigateHighlights) {
+      return
+    }
+    return setTimeout(() => {
+      const firstRow = this.editor.getFirstVisibleRow()
+      const lastRow = this.editor.getLastVisibleRow()
+      let highlightsBefore = 0
+      let highlightsAfter = 0
+      this.lastHiddenHighlightBefore = null
+      this.firstHiddenHighlightAfter = null
+      for (let annotation of Array.from(this.$scope.highlights || [])) {
+        const range = annotation.highlight || annotation.strikeThrough
+        if (range == null) {
+          continue
+        }
+        if (range.start.row < firstRow) {
+          highlightsBefore += 1
+          this.lastHiddenHighlightBefore = range
+        }
+        if (range.end.row > lastRow) {
+          highlightsAfter += 1
+          if (!this.firstHiddenHighlightAfter) {
+            this.firstHiddenHighlightAfter = range
           }
-          this.editor.scrollToLine(range.start.row, true, false)
-          break
         }
-        return result
-      })()
-    }
-
-    _clearMarkers() {
-      for (let marker_id of Array.from(this.markerIds)) {
-        this.editor.getSession().removeMarker(marker_id)
       }
-      return (this.markerIds = [])
-    }
 
-    _clearLabels() {
-      return (this.labels = [])
-    }
+      return this.$scope.$apply(() => {
+        return (this.$scope.updateLabels = {
+          highlightsBefore,
+          highlightsAfter
+        })
+      })
+    }, 100)
+  }
 
-    _drawCursor(annotation, colorScheme) {
-      return this._addMarkerWithCustomStyle(
-        new Range(
-          annotation.cursor.row,
-          annotation.cursor.column,
-          annotation.cursor.row,
-          annotation.cursor.column + 1
-        ),
-        'annotation remote-cursor',
-        false,
-        `border-color: ${colorScheme.cursor};`
-      )
-    }
+  scrollToFirstHighlight() {
+    return (() => {
+      const result = []
+      for (let annotation of Array.from(this.$scope.highlights || [])) {
+        const range = annotation.highlight || annotation.strikeThrough
+        if (range == null) {
+          continue
+        }
+        this.editor.scrollToLine(range.start.row, true, false)
+        break
+      }
+      return result
+    })()
+  }
 
-    _drawHighlight(annotation, colorScheme) {
-      return this._addMarkerWithCustomStyle(
-        new Range(
-          annotation.highlight.start.row,
-          annotation.highlight.start.column,
-          annotation.highlight.end.row,
-          annotation.highlight.end.column
-        ),
-        'annotation highlight',
-        false,
-        `background-color: ${colorScheme.highlightBackgroundColor}`
-      )
+  _clearMarkers() {
+    for (let marker_id of Array.from(this.markerIds)) {
+      this.editor.getSession().removeMarker(marker_id)
     }
-
-    _drawStrikeThrough(annotation, colorScheme) {
-      this._addMarkerWithCustomStyle(
-        new Range(
-          annotation.strikeThrough.start.row,
-          annotation.strikeThrough.start.column,
-          annotation.strikeThrough.end.row,
-          annotation.strikeThrough.end.column
-        ),
-        'annotation strike-through-background',
-        false,
-        `background-color: ${colorScheme.strikeThroughBackgroundColor}`
-      )
-      return this._addMarkerWithCustomStyle(
-        new Range(
-          annotation.strikeThrough.start.row,
-          annotation.strikeThrough.start.column,
-          annotation.strikeThrough.end.row,
-          annotation.strikeThrough.end.column
-        ),
-        'annotation strike-through-foreground',
-        true,
-        `color: ${colorScheme.strikeThroughForegroundColor};`
-      )
+    return (this.markerIds = [])
+  }
+
+  _clearLabels() {
+    return (this.labels = [])
+  }
+
+  _drawCursor(annotation, colorScheme) {
+    return this._addMarkerWithCustomStyle(
+      new Range(
+        annotation.cursor.row,
+        annotation.cursor.column,
+        annotation.cursor.row,
+        annotation.cursor.column + 1
+      ),
+      'annotation remote-cursor',
+      false,
+      `border-color: ${colorScheme.cursor};`
+    )
+  }
+
+  _drawHighlight(annotation, colorScheme) {
+    return this._addMarkerWithCustomStyle(
+      new Range(
+        annotation.highlight.start.row,
+        annotation.highlight.start.column,
+        annotation.highlight.end.row,
+        annotation.highlight.end.column
+      ),
+      'annotation highlight',
+      false,
+      `background-color: ${colorScheme.highlightBackgroundColor}`
+    )
+  }
+
+  _drawStrikeThrough(annotation, colorScheme) {
+    this._addMarkerWithCustomStyle(
+      new Range(
+        annotation.strikeThrough.start.row,
+        annotation.strikeThrough.start.column,
+        annotation.strikeThrough.end.row,
+        annotation.strikeThrough.end.column
+      ),
+      'annotation strike-through-background',
+      false,
+      `background-color: ${colorScheme.strikeThroughBackgroundColor}`
+    )
+    return this._addMarkerWithCustomStyle(
+      new Range(
+        annotation.strikeThrough.start.row,
+        annotation.strikeThrough.start.column,
+        annotation.strikeThrough.end.row,
+        annotation.strikeThrough.end.column
+      ),
+      'annotation strike-through-foreground',
+      true,
+      `color: ${colorScheme.strikeThroughForegroundColor};`
+    )
+  }
+
+  _addMarkerWithCustomStyle(range, klass, foreground, style) {
+    let markerLayer
+    if (!foreground) {
+      markerLayer = this.editor.renderer.$markerBack
+    } else {
+      markerLayer = this.editor.renderer.$markerFront
     }
 
-    _addMarkerWithCustomStyle(range, klass, foreground, style) {
-      let markerLayer
-      if (!foreground) {
-        markerLayer = this.editor.renderer.$markerBack
-      } else {
-        markerLayer = this.editor.renderer.$markerFront
-      }
-
-      return this.markerIds.push(
-        this.editor.getSession().addMarker(
-          range,
-          klass,
-          function(html, range, left, top, config) {
-            if (range.isMultiLine()) {
-              return markerLayer.drawTextMarker(
-                html,
-                range,
-                klass,
-                config,
-                style
-              )
-            } else {
-              return markerLayer.drawSingleLineMarker(
-                html,
-                range,
-                `${klass} ace_start`,
-                config,
-                0,
-                style
-              )
-            }
-          },
-          foreground
-        )
+    return this.markerIds.push(
+      this.editor.getSession().addMarker(
+        range,
+        klass,
+        function(html, range, left, top, config) {
+          if (range.isMultiLine()) {
+            return markerLayer.drawTextMarker(html, range, klass, config, style)
+          } else {
+            return markerLayer.drawSingleLineMarker(
+              html,
+              range,
+              `${klass} ace_start`,
+              config,
+              0,
+              style
+            )
+          }
+        },
+        foreground
       )
-    }
-  })
+    )
+  }
 })
 
 function __guard__(value, transform) {

+ 74 - 78
services/web/frontend/js/ide/editor/directives/aceEditor/metadata/MetadataManager.js

@@ -11,93 +11,89 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['ace/ace'], function() {
-  let MetadataManager
-  const { Range } = ace.require('ace/range')
+import 'ace/ace'
+let MetadataManager
+const { Range } = ace.require('ace/range')
 
-  const getLastCommandFragment = function(lineUpToCursor) {
-    let m
-    if ((m = lineUpToCursor.match(/(\\[^\\]+)$/))) {
-      return m[1]
-    } else {
-      return null
-    }
+const getLastCommandFragment = function(lineUpToCursor) {
+  let m
+  if ((m = lineUpToCursor.match(/(\\[^\\]+)$/))) {
+    return m[1]
+  } else {
+    return null
   }
+}
 
-  return (MetadataManager = class MetadataManager {
-    constructor($scope, editor, element, Metadata) {
-      this.$scope = $scope
-      this.editor = editor
-      this.element = element
-      this.Metadata = Metadata
-      this.debouncer = {} // DocId => Timeout
+export default (MetadataManager = class MetadataManager {
+  constructor($scope, editor, element, Metadata) {
+    this.$scope = $scope
+    this.editor = editor
+    this.element = element
+    this.Metadata = Metadata
+    this.debouncer = {} // DocId => Timeout
 
-      const onChange = change => {
-        if (change.remote) {
-          return
-        }
-        if (!['remove', 'insert'].includes(change.action)) {
-          return
-        }
-        const cursorPosition = this.editor.getCursorPosition()
-        const { end } = change
-        let range = new Range(end.row, 0, end.row, end.column)
-        let lineUpToCursor = this.editor.getSession().getTextRange(range)
-        if (
-          lineUpToCursor.trim() === '%' ||
-          lineUpToCursor.slice(0, 1) === '\\'
-        ) {
-          range = new Range(end.row, 0, end.row, end.column + 80)
-          lineUpToCursor = this.editor.getSession().getTextRange(range)
-        }
-        const commandFragment = getLastCommandFragment(lineUpToCursor)
+    const onChange = change => {
+      if (change.remote) {
+        return
+      }
+      if (!['remove', 'insert'].includes(change.action)) {
+        return
+      }
+      const cursorPosition = this.editor.getCursorPosition()
+      const { end } = change
+      let range = new Range(end.row, 0, end.row, end.column)
+      let lineUpToCursor = this.editor.getSession().getTextRange(range)
+      if (
+        lineUpToCursor.trim() === '%' ||
+        lineUpToCursor.slice(0, 1) === '\\'
+      ) {
+        range = new Range(end.row, 0, end.row, end.column + 80)
+        lineUpToCursor = this.editor.getSession().getTextRange(range)
+      }
+      const commandFragment = getLastCommandFragment(lineUpToCursor)
 
-        const linesContainPackage = _.some(change.lines, line =>
-          line.match(/^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
-        )
-        const linesContainReqPackage = _.some(change.lines, line =>
-          line.match(/^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
-        )
-        const linesContainLabel = _.some(change.lines, line =>
-          line.match(/\\label{(.{0,80}?)}/)
-        )
-        const linesContainMeta =
-          linesContainPackage || linesContainLabel || linesContainReqPackage
+      const linesContainPackage = _.some(change.lines, line =>
+        line.match(/^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
+      )
+      const linesContainReqPackage = _.some(change.lines, line =>
+        line.match(/^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
+      )
+      const linesContainLabel = _.some(change.lines, line =>
+        line.match(/\\label{(.{0,80}?)}/)
+      )
+      const linesContainMeta =
+        linesContainPackage || linesContainLabel || linesContainReqPackage
 
-        const lastCommandFragmentIsLabel =
-          (commandFragment != null
-            ? commandFragment.slice(0, 7)
-            : undefined) === '\\label{'
-        const lastCommandFragmentIsPackage =
-          (commandFragment != null
-            ? commandFragment.slice(0, 11)
-            : undefined) === '\\usepackage'
-        const lastCommandFragmentIsReqPack =
-          (commandFragment != null
-            ? commandFragment.slice(0, 15)
-            : undefined) === '\\RequirePackage'
-        const lastCommandFragmentIsMeta =
-          lastCommandFragmentIsPackage ||
-          lastCommandFragmentIsLabel ||
-          lastCommandFragmentIsReqPack
+      const lastCommandFragmentIsLabel =
+        (commandFragment != null ? commandFragment.slice(0, 7) : undefined) ===
+        '\\label{'
+      const lastCommandFragmentIsPackage =
+        (commandFragment != null ? commandFragment.slice(0, 11) : undefined) ===
+        '\\usepackage'
+      const lastCommandFragmentIsReqPack =
+        (commandFragment != null ? commandFragment.slice(0, 15) : undefined) ===
+        '\\RequirePackage'
+      const lastCommandFragmentIsMeta =
+        lastCommandFragmentIsPackage ||
+        lastCommandFragmentIsLabel ||
+        lastCommandFragmentIsReqPack
 
-        if (linesContainMeta || lastCommandFragmentIsMeta) {
-          return this.Metadata.scheduleLoadDocMetaFromServer(this.$scope.docId)
-        }
+      if (linesContainMeta || lastCommandFragmentIsMeta) {
+        return this.Metadata.scheduleLoadDocMetaFromServer(this.$scope.docId)
       }
-
-      this.editor.on('changeSession', e => {
-        e.oldSession.off('change', onChange)
-        return e.session.on('change', onChange)
-      })
     }
 
-    getAllLabels() {
-      return this.Metadata.getAllLabels()
-    }
+    this.editor.on('changeSession', e => {
+      e.oldSession.off('change', onChange)
+      return e.session.on('change', onChange)
+    })
+  }
 
-    getAllPackages() {
-      return this.Metadata.getAllPackages()
-    }
-  })
+  getAllLabels() {
+    return this.Metadata.getAllLabels()
+  }
+
+  getAllPackages() {
+    return this.Metadata.getAllPackages()
+  }
 })

+ 108 - 114
services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.js

@@ -11,132 +11,126 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['ace/ace'], function() {
-  let HighlightedWordManager
-  const { Range } = ace.require('ace/range')
-
-  class Highlight {
-    constructor(markerId, range, options) {
-      this.markerId = markerId
-      this.range = range
-      this.word = options.word
-      this.suggestions = options.suggestions
-    }
+import 'ace/ace'
+let HighlightedWordManager
+const { Range } = ace.require('ace/range')
+
+class Highlight {
+  constructor(markerId, range, options) {
+    this.markerId = markerId
+    this.range = range
+    this.word = options.word
+    this.suggestions = options.suggestions
   }
+}
 
-  return (HighlightedWordManager = class HighlightedWordManager {
-    constructor(editor) {
-      this.editor = editor
-      this.reset()
-    }
-
-    reset() {
-      if (this.highlights != null) {
-        this.highlights.forEach(highlight => {
-          return this.editor.getSession().removeMarker(highlight.markerId)
-        })
-      }
-      return (this.highlights = [])
-    }
+export default (HighlightedWordManager = class HighlightedWordManager {
+  constructor(editor) {
+    this.editor = editor
+    this.reset()
+  }
 
-    addHighlight(options) {
-      const session = this.editor.getSession()
-      const doc = session.getDocument()
-      // Set up Range that will automatically update it's positions when the
-      // document changes
-      const range = new Range()
-      range.start = doc.createAnchor({
-        row: options.row,
-        column: options.column
-      })
-      range.end = doc.createAnchor({
-        row: options.row,
-        column: options.column + options.word.length
+  reset() {
+    if (this.highlights != null) {
+      this.highlights.forEach(highlight => {
+        return this.editor.getSession().removeMarker(highlight.markerId)
       })
-      // Prevent range from adding newly typed characters to the end of the word.
-      // This makes it appear as if the spelling error continues to the next word
-      // even after a space
-      range.end.$insertRight = true
-
-      const markerId = session.addMarker(
-        range,
-        'spelling-highlight',
-        'text',
-        false
-      )
-
-      return this.highlights.push(new Highlight(markerId, range, options))
     }
+    return (this.highlights = [])
+  }
 
-    removeHighlight(highlight) {
-      this.editor.getSession().removeMarker(highlight.markerId)
-      return (this.highlights = this.highlights.filter(hl => hl !== highlight))
-    }
+  addHighlight(options) {
+    const session = this.editor.getSession()
+    const doc = session.getDocument()
+    // Set up Range that will automatically update it's positions when the
+    // document changes
+    const range = new Range()
+    range.start = doc.createAnchor({
+      row: options.row,
+      column: options.column
+    })
+    range.end = doc.createAnchor({
+      row: options.row,
+      column: options.column + options.word.length
+    })
+    // Prevent range from adding newly typed characters to the end of the word.
+    // This makes it appear as if the spelling error continues to the next word
+    // even after a space
+    range.end.$insertRight = true
+
+    const markerId = session.addMarker(
+      range,
+      'spelling-highlight',
+      'text',
+      false
+    )
+
+    return this.highlights.push(new Highlight(markerId, range, options))
+  }
 
-    removeWord(word) {
-      return this.highlights
-        .filter(highlight => highlight.word === word)
-        .forEach(highlight => {
-          return this.removeHighlight(highlight)
-        })
-    }
+  removeHighlight(highlight) {
+    this.editor.getSession().removeMarker(highlight.markerId)
+    return (this.highlights = this.highlights.filter(hl => hl !== highlight))
+  }
 
-    clearRow(row) {
-      return this.highlights
-        .filter(highlight => highlight.range.start.row === row)
-        .forEach(highlight => {
-          return this.removeHighlight(highlight)
-        })
-    }
+  removeWord(word) {
+    return this.highlights
+      .filter(highlight => highlight.word === word)
+      .forEach(highlight => {
+        return this.removeHighlight(highlight)
+      })
+  }
 
-    findHighlightWithinRange(range) {
-      return _.find(this.highlights, highlight => {
-        return this._doesHighlightOverlapRange(
-          highlight,
-          range.start,
-          range.end
-        )
+  clearRow(row) {
+    return this.highlights
+      .filter(highlight => highlight.range.start.row === row)
+      .forEach(highlight => {
+        return this.removeHighlight(highlight)
       })
-    }
+  }
 
-    _doesHighlightOverlapRange(highlight, start, end) {
-      const highlightRow = highlight.range.start.row
-      const highlightStartColumn = highlight.range.start.column
-      const highlightEndColumn = highlight.range.end.column
-
-      const highlightIsAllBeforeRange =
-        highlightRow < start.row ||
-        (highlightRow === start.row && highlightEndColumn <= start.column)
-      const highlightIsAllAfterRange =
-        highlightRow > end.row ||
-        (highlightRow === end.row && highlightStartColumn >= end.column)
-      return !(highlightIsAllBeforeRange || highlightIsAllAfterRange)
-    }
+  findHighlightWithinRange(range) {
+    return _.find(this.highlights, highlight => {
+      return this._doesHighlightOverlapRange(highlight, range.start, range.end)
+    })
+  }
 
-    clearHighlightTouchingRange(range) {
-      const highlight = _.find(this.highlights, hl => {
-        return this._doesHighlightTouchRange(hl, range.start, range.end)
-      })
-      if (highlight) {
-        return this.removeHighlight(highlight)
-      }
-    }
+  _doesHighlightOverlapRange(highlight, start, end) {
+    const highlightRow = highlight.range.start.row
+    const highlightStartColumn = highlight.range.start.column
+    const highlightEndColumn = highlight.range.end.column
+
+    const highlightIsAllBeforeRange =
+      highlightRow < start.row ||
+      (highlightRow === start.row && highlightEndColumn <= start.column)
+    const highlightIsAllAfterRange =
+      highlightRow > end.row ||
+      (highlightRow === end.row && highlightStartColumn >= end.column)
+    return !(highlightIsAllBeforeRange || highlightIsAllAfterRange)
+  }
 
-    _doesHighlightTouchRange(highlight, start, end) {
-      const highlightRow = highlight.range.start.row
-      const highlightStartColumn = highlight.range.start.column
-      const highlightEndColumn = highlight.range.end.column
-
-      const rangeStartIsWithinHighlight =
-        highlightStartColumn <= start.column &&
-        highlightEndColumn >= start.column
-      const rangeEndIsWithinHighlight =
-        highlightStartColumn <= end.column && highlightEndColumn >= end.column
-
-      return (
-        highlightRow === start.row &&
-        (rangeStartIsWithinHighlight || rangeEndIsWithinHighlight)
-      )
+  clearHighlightTouchingRange(range) {
+    const highlight = _.find(this.highlights, hl => {
+      return this._doesHighlightTouchRange(hl, range.start, range.end)
+    })
+    if (highlight) {
+      return this.removeHighlight(highlight)
     }
-  })
+  }
+
+  _doesHighlightTouchRange(highlight, start, end) {
+    const highlightRow = highlight.range.start.row
+    const highlightStartColumn = highlight.range.start.column
+    const highlightEndColumn = highlight.range.end.column
+
+    const rangeStartIsWithinHighlight =
+      highlightStartColumn <= start.column && highlightEndColumn >= start.column
+    const rangeEndIsWithinHighlight =
+      highlightStartColumn <= end.column && highlightEndColumn >= end.column
+
+    return (
+      highlightRow === start.row &&
+      (rangeStartIsWithinHighlight || rangeEndIsWithinHighlight)
+    )
+  }
 })

+ 77 - 80
services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.js

@@ -1,99 +1,96 @@
-define(['ace/ace', './HighlightedWordManager'], function(
-  Ace,
-  HighlightedWordManager
-) {
-  const { Range } = ace.require('ace/range')
-
-  class SpellCheckAdapter {
-    constructor(editor) {
-      this.replaceWord = this.replaceWord.bind(this)
-      this.editor = editor
-      this.highlightedWordManager = new HighlightedWordManager(this.editor)
-    }
+import HighlightedWordManager from './HighlightedWordManager'
+import 'ace/ace'
+const { Range } = ace.require('ace/range')
+
+class SpellCheckAdapter {
+  constructor(editor) {
+    this.replaceWord = this.replaceWord.bind(this)
+    this.editor = editor
+    this.highlightedWordManager = new HighlightedWordManager(this.editor)
+  }
 
-    getLines() {
-      return this.editor.getValue().split('\n')
-    }
+  getLines() {
+    return this.editor.getValue().split('\n')
+  }
 
-    getLineCount() {
-      return this.editor.session.getLength()
-    }
+  getLineCount() {
+    return this.editor.session.getLength()
+  }
 
-    getFirstVisibleRowNum() {
-      return this.editor.renderer.layerConfig.firstRow
-    }
+  getFirstVisibleRowNum() {
+    return this.editor.renderer.layerConfig.firstRow
+  }
 
-    getLastVisibleRowNum() {
-      return this.editor.renderer.layerConfig.lastRow
-    }
+  getLastVisibleRowNum() {
+    return this.editor.renderer.layerConfig.lastRow
+  }
 
-    getLinesByRows(rows) {
-      return rows.map(rowIdx => this.editor.session.doc.getLine(rowIdx))
-    }
+  getLinesByRows(rows) {
+    return rows.map(rowIdx => this.editor.session.doc.getLine(rowIdx))
+  }
 
-    getSelectionContents() {
-      return this.editor.getSelectedText()
-    }
+  getSelectionContents() {
+    return this.editor.getSelectedText()
+  }
 
-    normalizeChangeEvent(e) {
-      return e
-    }
+  normalizeChangeEvent(e) {
+    return e
+  }
 
-    getCoordsFromContextMenuEvent(e) {
-      e.domEvent.stopPropagation()
-      return {
-        x: e.domEvent.clientX,
-        y: e.domEvent.clientY
-      }
+  getCoordsFromContextMenuEvent(e) {
+    e.domEvent.stopPropagation()
+    return {
+      x: e.domEvent.clientX,
+      y: e.domEvent.clientY
     }
+  }
 
-    preventContextMenuEventDefault(e) {
-      e.domEvent.preventDefault()
-    }
+  preventContextMenuEventDefault(e) {
+    e.domEvent.preventDefault()
+  }
 
-    getHighlightFromCoords(coords) {
-      const position = this.editor.renderer.screenToTextCoordinates(
-        coords.x,
-        coords.y
-      )
-      return this.highlightedWordManager.findHighlightWithinRange({
-        start: position,
-        end: position
-      })
-    }
+  getHighlightFromCoords(coords) {
+    const position = this.editor.renderer.screenToTextCoordinates(
+      coords.x,
+      coords.y
+    )
+    return this.highlightedWordManager.findHighlightWithinRange({
+      start: position,
+      end: position
+    })
+  }
 
-    isContextMenuEventOnBottomHalf(e) {
-      const { clientY } = e.domEvent
-      const editorBoundingRect = e.target.container.getBoundingClientRect()
-      const relativeYPos =
-        (clientY - editorBoundingRect.top) / editorBoundingRect.height
-      return relativeYPos > 0.5
-    }
+  isContextMenuEventOnBottomHalf(e) {
+    const { clientY } = e.domEvent
+    const editorBoundingRect = e.target.container.getBoundingClientRect()
+    const relativeYPos =
+      (clientY - editorBoundingRect.top) / editorBoundingRect.height
+    return relativeYPos > 0.5
+  }
 
-    selectHighlightedWord(highlight) {
-      const { row } = highlight.range.start
-      const startColumn = highlight.range.start.column
-      const endColumn = highlight.range.end.column
+  selectHighlightedWord(highlight) {
+    const { row } = highlight.range.start
+    const startColumn = highlight.range.start.column
+    const endColumn = highlight.range.end.column
 
-      this.editor
-        .getSession()
-        .getSelection()
-        .setSelectionRange(new Range(row, startColumn, row, endColumn))
-    }
+    this.editor
+      .getSession()
+      .getSelection()
+      .setSelectionRange(new Range(row, startColumn, row, endColumn))
+  }
 
-    replaceWord(highlight, newWord) {
-      const { row } = highlight.range.start
-      const startColumn = highlight.range.start.column
-      const endColumn = highlight.range.end.column
+  replaceWord(highlight, newWord) {
+    const { row } = highlight.range.start
+    const startColumn = highlight.range.start.column
+    const endColumn = highlight.range.end.column
 
-      this.editor
-        .getSession()
-        .replace(new Range(row, startColumn, row, endColumn), newWord)
+    this.editor
+      .getSession()
+      .replace(new Range(row, startColumn, row, endColumn), newWord)
 
-      // Bring editor back into focus after clicking on suggestion
-      this.editor.focus()
-    }
+    // Bring editor back into focus after clicking on suggestion
+    this.editor.focus()
   }
+}
 
-  return SpellCheckAdapter
-})
+export default SpellCheckAdapter

File diff suppressed because it is too large
+ 2 - 7
services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js


+ 203 - 211
services/web/frontend/js/ide/editor/directives/aceEditor/track-changes/TrackChangesAdapter.js

@@ -1,39 +1,137 @@
 /* eslint-disable
     camelcase
  */
-define(['ace/ace', '../../../EditorShareJsCodec'], function(
-  _ignore,
-  EditorShareJsCodec
-) {
-  const { Range } = ace.require('ace/range')
-  class TrackChangesAdapter {
-    constructor(editor) {
-      this.editor = editor
-      this.changeIdToMarkerIdMap = {}
+import EditorShareJsCodec from '../../../EditorShareJsCodec'
+import 'ace/ace'
+const { Range } = ace.require('ace/range')
+class TrackChangesAdapter {
+  constructor(editor) {
+    this.editor = editor
+    this.changeIdToMarkerIdMap = {}
+  }
+
+  tearDown() {
+    this.changeIdToMarkerIdMap = {}
+  }
+
+  clearAnnotations() {
+    const session = this.editor.getSession()
+    for (let change_id in this.changeIdToMarkerIdMap) {
+      const markers = this.changeIdToMarkerIdMap[change_id]
+      for (let marker_name in markers) {
+        const marker_id = markers[marker_name]
+        session.removeMarker(marker_id)
+      }
     }
+    this.changeIdToMarkerIdMap = {}
+  }
 
-    tearDown() {
-      this.changeIdToMarkerIdMap = {}
+  onInsertAdded(change) {
+    const start = this.shareJsOffsetToRowColumn(change.op.p)
+    const end = this.shareJsOffsetToRowColumn(change.op.p + change.op.i.length)
+
+    const session = this.editor.getSession()
+    const background_range = new Range(
+      start.row,
+      start.column,
+      end.row,
+      end.column
+    )
+    const background_marker_id = session.addMarker(
+      background_range,
+      'track-changes-marker track-changes-added-marker',
+      'text'
+    )
+    const callout_marker_id = this.createCalloutMarker(
+      start,
+      'track-changes-added-marker-callout'
+    )
+
+    this.changeIdToMarkerIdMap[change.id] = {
+      background_marker_id,
+      callout_marker_id
     }
+  }
 
-    clearAnnotations() {
-      const session = this.editor.getSession()
-      for (let change_id in this.changeIdToMarkerIdMap) {
-        const markers = this.changeIdToMarkerIdMap[change_id]
-        for (let marker_name in markers) {
-          const marker_id = markers[marker_name]
-          session.removeMarker(marker_id)
-        }
-      }
-      this.changeIdToMarkerIdMap = {}
+  onDeleteAdded(change) {
+    const position = this.shareJsOffsetToRowColumn(change.op.p)
+    const session = this.editor.getSession()
+
+    const markerLayer = this.editor.renderer.$markerBack
+    const klass = 'track-changes-marker track-changes-deleted-marker'
+    const background_range = this.makeZeroWidthRange(position)
+    const background_marker_id = session.addMarker(
+      background_range,
+      klass,
+      (html, range, left, top, config) =>
+        markerLayer.drawSingleLineMarker(
+          html,
+          range,
+          `${klass} ace_start`,
+          config,
+          0,
+          ''
+        )
+    )
+
+    const callout_marker_id = this.createCalloutMarker(
+      position,
+      'track-changes-deleted-marker-callout'
+    )
+
+    this.changeIdToMarkerIdMap[change.id] = {
+      background_marker_id,
+      callout_marker_id
+    }
+  }
+
+  onInsertRemoved(change) {
+    if (this.changeIdToMarkerIdMap[change.id] == null) {
+      return
     }
+    const {
+      background_marker_id,
+      callout_marker_id
+    } = this.changeIdToMarkerIdMap[change.id]
+    delete this.changeIdToMarkerIdMap[change.id]
+    const session = this.editor.getSession()
+    session.removeMarker(background_marker_id)
+    session.removeMarker(callout_marker_id)
+  }
+
+  onDeleteRemoved(change) {
+    if (this.changeIdToMarkerIdMap[change.id] == null) {
+      return
+    }
+    const {
+      background_marker_id,
+      callout_marker_id
+    } = this.changeIdToMarkerIdMap[change.id]
+    delete this.changeIdToMarkerIdMap[change.id]
+
+    const session = this.editor.getSession()
+    session.removeMarker(background_marker_id)
+    session.removeMarker(callout_marker_id)
+  }
+
+  onChangeMoved(change) {
+    let end
+    const start = this.shareJsOffsetToRowColumn(change.op.p)
+    if (change.op.i != null) {
+      end = this.shareJsOffsetToRowColumn(change.op.p + change.op.i.length)
+    } else {
+      end = start
+    }
+    this.updateMarker(change.id, start, end)
+  }
 
-    onInsertAdded(change) {
-      const start = this.shareJsOffsetToRowColumn(change.op.p)
+  onCommentAdded(comment) {
+    if (this.changeIdToMarkerIdMap[comment.id] == null) {
+      // Only create new markers if they don't already exist
+      const start = this.shareJsOffsetToRowColumn(comment.op.p)
       const end = this.shareJsOffsetToRowColumn(
-        change.op.p + change.op.i.length
+        comment.op.p + comment.op.c.length
       )
-
       const session = this.editor.getSession()
       const background_range = new Range(
         start.row,
@@ -43,217 +141,111 @@ define(['ace/ace', '../../../EditorShareJsCodec'], function(
       )
       const background_marker_id = session.addMarker(
         background_range,
-        'track-changes-marker track-changes-added-marker',
+        'track-changes-marker track-changes-comment-marker',
         'text'
       )
       const callout_marker_id = this.createCalloutMarker(
         start,
-        'track-changes-added-marker-callout'
+        'track-changes-comment-marker-callout'
       )
-
-      this.changeIdToMarkerIdMap[change.id] = {
-        background_marker_id,
-        callout_marker_id
-      }
-    }
-
-    onDeleteAdded(change) {
-      const position = this.shareJsOffsetToRowColumn(change.op.p)
-      const session = this.editor.getSession()
-
-      const markerLayer = this.editor.renderer.$markerBack
-      const klass = 'track-changes-marker track-changes-deleted-marker'
-      const background_range = this.makeZeroWidthRange(position)
-      const background_marker_id = session.addMarker(
-        background_range,
-        klass,
-        (html, range, left, top, config) =>
-          markerLayer.drawSingleLineMarker(
-            html,
-            range,
-            `${klass} ace_start`,
-            config,
-            0,
-            ''
-          )
-      )
-
-      const callout_marker_id = this.createCalloutMarker(
-        position,
-        'track-changes-deleted-marker-callout'
-      )
-
-      this.changeIdToMarkerIdMap[change.id] = {
+      this.changeIdToMarkerIdMap[comment.id] = {
         background_marker_id,
         callout_marker_id
       }
     }
+  }
 
-    onInsertRemoved(change) {
-      if (this.changeIdToMarkerIdMap[change.id] == null) {
-        return
-      }
-      const {
-        background_marker_id,
-        callout_marker_id
-      } = this.changeIdToMarkerIdMap[change.id]
-      delete this.changeIdToMarkerIdMap[change.id]
-      const session = this.editor.getSession()
-      session.removeMarker(background_marker_id)
-      session.removeMarker(callout_marker_id)
-    }
+  onCommentMoved(comment) {
+    const start = this.shareJsOffsetToRowColumn(comment.op.p)
+    const end = this.shareJsOffsetToRowColumn(
+      comment.op.p + comment.op.c.length
+    )
+    this.updateMarker(comment.id, start, end)
+  }
 
-    onDeleteRemoved(change) {
-      if (this.changeIdToMarkerIdMap[change.id] == null) {
-        return
-      }
+  onCommentRemoved(comment) {
+    if (this.changeIdToMarkerIdMap[comment.id] != null) {
+      // Resolved comments may not have marker ids
       const {
         background_marker_id,
         callout_marker_id
-      } = this.changeIdToMarkerIdMap[change.id]
-      delete this.changeIdToMarkerIdMap[change.id]
-
+      } = this.changeIdToMarkerIdMap[comment.id]
+      delete this.changeIdToMarkerIdMap[comment.id]
       const session = this.editor.getSession()
       session.removeMarker(background_marker_id)
       session.removeMarker(callout_marker_id)
     }
+  }
 
-    onChangeMoved(change) {
-      let end
-      const start = this.shareJsOffsetToRowColumn(change.op.p)
-      if (change.op.i != null) {
-        end = this.shareJsOffsetToRowColumn(change.op.p + change.op.i.length)
-      } else {
-        end = start
-      }
-      this.updateMarker(change.id, start, end)
+  updateMarker(change_id, start, end) {
+    if (this.changeIdToMarkerIdMap[change_id] == null) {
+      return
     }
-
-    onCommentAdded(comment) {
-      if (this.changeIdToMarkerIdMap[comment.id] == null) {
-        // Only create new markers if they don't already exist
-        const start = this.shareJsOffsetToRowColumn(comment.op.p)
-        const end = this.shareJsOffsetToRowColumn(
-          comment.op.p + comment.op.c.length
-        )
-        const session = this.editor.getSession()
-        const background_range = new Range(
-          start.row,
-          start.column,
-          end.row,
-          end.column
-        )
-        const background_marker_id = session.addMarker(
-          background_range,
-          'track-changes-marker track-changes-comment-marker',
-          'text'
-        )
-        const callout_marker_id = this.createCalloutMarker(
-          start,
-          'track-changes-comment-marker-callout'
-        )
-        this.changeIdToMarkerIdMap[comment.id] = {
-          background_marker_id,
-          callout_marker_id
-        }
-      }
+    const session = this.editor.getSession()
+    const markers = session.getMarkers()
+    const {
+      background_marker_id,
+      callout_marker_id
+    } = this.changeIdToMarkerIdMap[change_id]
+    if (background_marker_id != null && markers[background_marker_id] != null) {
+      const background_marker = markers[background_marker_id]
+      background_marker.range.start = start
+      background_marker.range.end = end
     }
-
-    onCommentMoved(comment) {
-      const start = this.shareJsOffsetToRowColumn(comment.op.p)
-      const end = this.shareJsOffsetToRowColumn(
-        comment.op.p + comment.op.c.length
-      )
-      this.updateMarker(comment.id, start, end)
-    }
-
-    onCommentRemoved(comment) {
-      if (this.changeIdToMarkerIdMap[comment.id] != null) {
-        // Resolved comments may not have marker ids
-        const {
-          background_marker_id,
-          callout_marker_id
-        } = this.changeIdToMarkerIdMap[comment.id]
-        delete this.changeIdToMarkerIdMap[comment.id]
-        const session = this.editor.getSession()
-        session.removeMarker(background_marker_id)
-        session.removeMarker(callout_marker_id)
-      }
-    }
-
-    updateMarker(change_id, start, end) {
-      if (this.changeIdToMarkerIdMap[change_id] == null) {
-        return
-      }
-      const session = this.editor.getSession()
-      const markers = session.getMarkers()
-      const {
-        background_marker_id,
-        callout_marker_id
-      } = this.changeIdToMarkerIdMap[change_id]
-      if (
-        background_marker_id != null &&
-        markers[background_marker_id] != null
-      ) {
-        const background_marker = markers[background_marker_id]
-        background_marker.range.start = start
-        background_marker.range.end = end
-      }
-      if (callout_marker_id != null && markers[callout_marker_id] != null) {
-        const callout_marker = markers[callout_marker_id]
-        callout_marker.range.start = start
-        callout_marker.range.end = start
-      }
+    if (callout_marker_id != null && markers[callout_marker_id] != null) {
+      const callout_marker = markers[callout_marker_id]
+      callout_marker.range.start = start
+      callout_marker.range.end = start
     }
+  }
 
-    shareJsOffsetToRowColumn(offset) {
-      const lines = this.editor
-        .getSession()
-        .getDocument()
-        .getAllLines()
-      return EditorShareJsCodec.shareJsOffsetToRowColumn(offset, lines)
-    }
+  shareJsOffsetToRowColumn(offset) {
+    const lines = this.editor
+      .getSession()
+      .getDocument()
+      .getAllLines()
+    return EditorShareJsCodec.shareJsOffsetToRowColumn(offset, lines)
+  }
 
-    createCalloutMarker(position, klass) {
-      const session = this.editor.getSession()
-      const callout_range = this.makeZeroWidthRange(position)
-      const markerLayer = this.editor.renderer.$markerBack
-      return session.addMarker(
-        callout_range,
-        klass,
-        (html, range, left, top, config) =>
-          markerLayer.drawSingleLineMarker(
-            html,
-            range,
-            `track-changes-marker-callout ${klass} ace_start`,
-            config,
-            0,
-            'width: auto; right: 0;'
-          )
-      )
-    }
+  createCalloutMarker(position, klass) {
+    const session = this.editor.getSession()
+    const callout_range = this.makeZeroWidthRange(position)
+    const markerLayer = this.editor.renderer.$markerBack
+    return session.addMarker(
+      callout_range,
+      klass,
+      (html, range, left, top, config) =>
+        markerLayer.drawSingleLineMarker(
+          html,
+          range,
+          `track-changes-marker-callout ${klass} ace_start`,
+          config,
+          0,
+          'width: auto; right: 0;'
+        )
+    )
+  }
 
-    makeZeroWidthRange(position) {
-      const ace_range = new Range(
-        position.row,
-        position.column,
-        position.row,
-        position.column
-      )
-      // Our delete marker is zero characters wide, but Ace doesn't draw ranges
-      // that are empty. So we monkey patch the range to tell Ace it's not empty
-      // We do want to claim to be empty if we're off screen after clipping rows
-      //  though. This is the code we need to trick:
-      //   var range = marker.range.clipRows(config.firstRow, config.lastRow);
-      //   if (range.isEmpty()) continue;
-      ace_range.clipRows = function(first_row, last_row) {
-        this.isEmpty = function() {
-          return first_row > this.end.row || last_row < this.start.row
-        }
-        return this
+  makeZeroWidthRange(position) {
+    const ace_range = new Range(
+      position.row,
+      position.column,
+      position.row,
+      position.column
+    )
+    // Our delete marker is zero characters wide, but Ace doesn't draw ranges
+    // that are empty. So we monkey patch the range to tell Ace it's not empty
+    // We do want to claim to be empty if we're off screen after clipping rows
+    //  though. This is the code we need to trick:
+    //   var range = marker.range.clipRows(config.firstRow, config.lastRow);
+    //   if (range.isEmpty()) continue;
+    ace_range.clipRows = function(first_row, last_row) {
+      this.isEmpty = function() {
+        return first_row > this.end.row || last_row < this.start.row
       }
-      return ace_range
+      return this
     }
+    return ace_range
   }
-  return TrackChangesAdapter
-})
+}
+export default TrackChangesAdapter

+ 569 - 579
services/web/frontend/js/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.js

@@ -2,678 +2,668 @@
     camelcase,
     max-len
  */
-define([
-  'ace/ace',
-  '../../../../../utils/EventEmitter',
-  '../../../../colors/ColorManager',
-  '../../../EditorShareJsCodec'
-], function(_ignore, EventEmitter, ColorManager, EditorShareJsCodec) {
-  const { Range } = ace.require('ace/range')
-  class TrackChangesManager {
-    constructor($scope, editor, element, adapter) {
-      this._doneUpdateThisLoop = false
-      this._pendingUpdates = false
-
-      this.onChangeSession = this.onChangeSession.bind(this)
-      this.onChangeSelection = this.onChangeSelection.bind(this)
-      this.onCut = this.onCut.bind(this)
-      this.onPaste = this.onPaste.bind(this)
-      this.onResize = this.onResize.bind(this)
-      this.tearDown = this.tearDown.bind(this)
-
-      this.$scope = $scope
-      this.editor = editor
-      this.element = element
-      this.adapter = adapter
-      this._scrollTimeout = null
-      this.changingSelection = false
-
-      if (window.trackChangesManager == null) {
-        window.trackChangesManager = this
+import EditorShareJsCodec from '../../../EditorShareJsCodec'
+import 'ace/ace'
+import '../../../../../utils/EventEmitter'
+import '../../../../colors/ColorManager'
+const { Range } = ace.require('ace/range')
+class TrackChangesManager {
+  constructor($scope, editor, element, adapter) {
+    this._doneUpdateThisLoop = false
+    this._pendingUpdates = false
+
+    this.onChangeSession = this.onChangeSession.bind(this)
+    this.onChangeSelection = this.onChangeSelection.bind(this)
+    this.onCut = this.onCut.bind(this)
+    this.onPaste = this.onPaste.bind(this)
+    this.onResize = this.onResize.bind(this)
+    this.tearDown = this.tearDown.bind(this)
+
+    this.$scope = $scope
+    this.editor = editor
+    this.element = element
+    this.adapter = adapter
+    this._scrollTimeout = null
+    this.changingSelection = false
+
+    if (window.trackChangesManager == null) {
+      window.trackChangesManager = this
+    }
+
+    this.$scope.$watch('trackChanges', track_changes => {
+      if (track_changes == null) {
+        return
       }
+      this.setTrackChanges(track_changes)
+    })
 
-      this.$scope.$watch('trackChanges', track_changes => {
-        if (track_changes == null) {
-          return
-        }
-        this.setTrackChanges(track_changes)
-      })
+    this.$scope.$watch('sharejsDoc', (doc, oldDoc) => {
+      if (doc == null) {
+        return
+      }
+      if (oldDoc != null) {
+        this.disconnectFromDoc(oldDoc)
+      }
+      this.setTrackChanges(this.$scope.trackChanges)
+      this.connectToDoc(doc)
+    })
 
-      this.$scope.$watch('sharejsDoc', (doc, oldDoc) => {
-        if (doc == null) {
-          return
-        }
-        if (oldDoc != null) {
-          this.disconnectFromDoc(oldDoc)
-        }
-        this.setTrackChanges(this.$scope.trackChanges)
-        this.connectToDoc(doc)
-      })
+    this.$scope.$on('comment:add', (e, thread_id, offset, length) => {
+      this.addCommentToSelection(thread_id, offset, length)
+    })
 
-      this.$scope.$on('comment:add', (e, thread_id, offset, length) => {
-        this.addCommentToSelection(thread_id, offset, length)
-      })
+    this.$scope.$on('comment:select_line', e => {
+      this.selectLineIfNoSelection()
+    })
 
-      this.$scope.$on('comment:select_line', e => {
-        this.selectLineIfNoSelection()
-      })
+    this.$scope.$on('changes:accept', (e, change_ids) => {
+      this.acceptChangeIds(change_ids)
+    })
 
-      this.$scope.$on('changes:accept', (e, change_ids) => {
-        this.acceptChangeIds(change_ids)
-      })
+    this.$scope.$on('changes:reject', (e, change_ids) => {
+      this.rejectChangeIds(change_ids)
+    })
 
-      this.$scope.$on('changes:reject', (e, change_ids) => {
-        this.rejectChangeIds(change_ids)
-      })
-
-      this.$scope.$on('comment:remove', (e, comment_id) => {
-        this.removeCommentId(comment_id)
-      })
+    this.$scope.$on('comment:remove', (e, comment_id) => {
+      this.removeCommentId(comment_id)
+    })
 
-      this.$scope.$on('comment:resolve_threads', (e, thread_ids) => {
-        this.hideCommentsByThreadIds(thread_ids)
-      })
+    this.$scope.$on('comment:resolve_threads', (e, thread_ids) => {
+      this.hideCommentsByThreadIds(thread_ids)
+    })
 
-      this.$scope.$on('comment:unresolve_thread', (e, thread_id) => {
-        this.showCommentByThreadId(thread_id)
-      })
+    this.$scope.$on('comment:unresolve_thread', (e, thread_id) => {
+      this.showCommentByThreadId(thread_id)
+    })
 
-      this.$scope.$on('review-panel:recalculate-screen-positions', () => {
-        this.recalculateReviewEntriesScreenPositions()
-      })
+    this.$scope.$on('review-panel:recalculate-screen-positions', () => {
+      this.recalculateReviewEntriesScreenPositions()
+    })
 
-      this._resetCutState()
-    }
+    this._resetCutState()
+  }
 
-    onChangeSession(e) {
-      this.clearAnnotations()
-      this.redrawAnnotations()
+  onChangeSession(e) {
+    this.clearAnnotations()
+    this.redrawAnnotations()
 
-      if (this.editor) {
-        this.editor.session.on(
-          'changeScrollTop',
-          this.onChangeScroll.bind(this)
-        )
-      }
+    if (this.editor) {
+      this.editor.session.on('changeScrollTop', this.onChangeScroll.bind(this))
     }
+  }
 
-    onChangeScroll() {
-      if (this._scrollTimeout != null) {
-      } else {
-        return (this._scrollTimeout = setTimeout(() => {
-          this.recalculateVisibleEntries()
-          this.$scope.$apply()
-          return (this._scrollTimeout = null)
-        }, 200))
-      }
+  onChangeScroll() {
+    if (this._scrollTimeout != null) {
+    } else {
+      return (this._scrollTimeout = setTimeout(() => {
+        this.recalculateVisibleEntries()
+        this.$scope.$apply()
+        return (this._scrollTimeout = null)
+      }, 200))
     }
+  }
 
-    onChangeSelection() {
-      // Deletes can send about 5 changeSelection events, so
-      // just act on the last one.
-      if (!this.changingSelection) {
-        this.changingSelection = true
-        return this.$scope.$evalAsync(() => {
-          this.changingSelection = false
-          return this.updateFocus()
-        })
-      }
+  onChangeSelection() {
+    // Deletes can send about 5 changeSelection events, so
+    // just act on the last one.
+    if (!this.changingSelection) {
+      this.changingSelection = true
+      return this.$scope.$evalAsync(() => {
+        this.changingSelection = false
+        return this.updateFocus()
+      })
     }
+  }
 
-    onResize() {
-      this.recalculateReviewEntriesScreenPositions()
-    }
+  onResize() {
+    this.recalculateReviewEntriesScreenPositions()
+  }
 
-    connectToDoc(doc) {
-      this.rangesTracker = doc.ranges
-      this.setTrackChanges(this.$scope.trackChanges)
+  connectToDoc(doc) {
+    this.rangesTracker = doc.ranges
+    this.setTrackChanges(this.$scope.trackChanges)
 
-      doc.on('ranges:dirty', () => {
-        this.updateAnnotations()
-      })
-      doc.on('ranges:clear', () => {
-        this.clearAnnotations()
-      })
-      doc.on('ranges:redraw', () => {
-        this.redrawAnnotations()
-      })
-    }
+    doc.on('ranges:dirty', () => {
+      this.updateAnnotations()
+    })
+    doc.on('ranges:clear', () => {
+      this.clearAnnotations()
+    })
+    doc.on('ranges:redraw', () => {
+      this.redrawAnnotations()
+    })
+  }
 
-    disconnectFromDoc(doc) {
-      doc.off('ranges:clear')
-      doc.off('ranges:redraw')
-      doc.off('ranges:dirty')
-    }
+  disconnectFromDoc(doc) {
+    doc.off('ranges:clear')
+    doc.off('ranges:redraw')
+    doc.off('ranges:dirty')
+  }
 
-    tearDown() {
-      this.adapter.tearDown()
-    }
+  tearDown() {
+    this.adapter.tearDown()
+  }
 
-    setTrackChanges(value) {
-      if (value) {
-        if (this.$scope.sharejsDoc != null) {
-          this.$scope.sharejsDoc.track_changes_as =
-            window.user.id || 'anonymous'
-        }
-      } else {
-        if (this.$scope.sharejsDoc != null) {
-          this.$scope.sharejsDoc.track_changes_as = null
-        }
+  setTrackChanges(value) {
+    if (value) {
+      if (this.$scope.sharejsDoc != null) {
+        this.$scope.sharejsDoc.track_changes_as = window.user.id || 'anonymous'
+      }
+    } else {
+      if (this.$scope.sharejsDoc != null) {
+        this.$scope.sharejsDoc.track_changes_as = null
       }
     }
+  }
 
-    clearAnnotations() {
-      this.adapter.clearAnnotations()
+  clearAnnotations() {
+    this.adapter.clearAnnotations()
+  }
+
+  redrawAnnotations() {
+    for (let change of Array.from(this.rangesTracker.changes)) {
+      if (change.op.i != null) {
+        this.adapter.onInsertAdded(change)
+      } else if (change.op.d != null) {
+        this.adapter.onDeleteAdded(change)
+      }
     }
 
-    redrawAnnotations() {
-      for (let change of Array.from(this.rangesTracker.changes)) {
-        if (change.op.i != null) {
-          this.adapter.onInsertAdded(change)
-        } else if (change.op.d != null) {
-          this.adapter.onDeleteAdded(change)
-        }
+    Array.from(this.rangesTracker.comments).map(comment => {
+      if (!this.isCommentResolved(comment)) {
+        this.adapter.onCommentAdded(comment)
       }
+    })
+
+    this.broadcastChange()
+  }
 
-      Array.from(this.rangesTracker.comments).map(comment => {
-        if (!this.isCommentResolved(comment)) {
-          this.adapter.onCommentAdded(comment)
+  updateAnnotations() {
+    // Doc updates with multiple ops, like search/replace or block comments
+    // will call this with every individual op in a single event loop. So only
+    // do the first this loop, then schedule an update for the next loop for
+    // the rest.
+    if (!this._doneUpdateThisLoop) {
+      this._doUpdateAnnotations()
+      this._doneUpdateThisLoop = true
+      return setTimeout(() => {
+        if (this._pendingUpdates) {
+          this._doUpdateAnnotations()
         }
+        this._doneUpdateThisLoop = false
+        this._pendingUpdates = false
       })
-
-      this.broadcastChange()
-    }
-
-    updateAnnotations() {
-      // Doc updates with multiple ops, like search/replace or block comments
-      // will call this with every individual op in a single event loop. So only
-      // do the first this loop, then schedule an update for the next loop for
-      // the rest.
-      if (!this._doneUpdateThisLoop) {
-        this._doUpdateAnnotations()
-        this._doneUpdateThisLoop = true
-        return setTimeout(() => {
-          if (this._pendingUpdates) {
-            this._doUpdateAnnotations()
-          }
-          this._doneUpdateThisLoop = false
-          this._pendingUpdates = false
-        })
-      } else {
-        this._pendingUpdates = true
-      }
+    } else {
+      this._pendingUpdates = true
     }
+  }
 
-    _doUpdateAnnotations() {
-      let change, comment
-      const dirty = this.rangesTracker.getDirtyState()
+  _doUpdateAnnotations() {
+    let change, comment
+    const dirty = this.rangesTracker.getDirtyState()
 
-      let updateMarkers = false
+    let updateMarkers = false
 
-      for (var id in dirty.change.added) {
-        change = dirty.change.added[id]
-        if (change.op.i != null) {
-          this.adapter.onInsertAdded(change)
-        } else if (change.op.d != null) {
-          this.adapter.onDeleteAdded(change)
-        }
+    for (var id in dirty.change.added) {
+      change = dirty.change.added[id]
+      if (change.op.i != null) {
+        this.adapter.onInsertAdded(change)
+      } else if (change.op.d != null) {
+        this.adapter.onDeleteAdded(change)
       }
-      for (id in dirty.change.removed) {
-        change = dirty.change.removed[id]
-        if (change.op.i != null) {
-          this.adapter.onInsertRemoved(change)
-        } else if (change.op.d != null) {
-          this.adapter.onDeleteRemoved(change)
-        }
-      }
-      for (id in dirty.change.moved) {
-        change = dirty.change.moved[id]
-        updateMarkers = true
-        this.adapter.onChangeMoved(change)
+    }
+    for (id in dirty.change.removed) {
+      change = dirty.change.removed[id]
+      if (change.op.i != null) {
+        this.adapter.onInsertRemoved(change)
+      } else if (change.op.d != null) {
+        this.adapter.onDeleteRemoved(change)
       }
+    }
+    for (id in dirty.change.moved) {
+      change = dirty.change.moved[id]
+      updateMarkers = true
+      this.adapter.onChangeMoved(change)
+    }
 
-      for (id in dirty.comment.added) {
-        comment = dirty.comment.added[id]
-        if (!this.isCommentResolved(comment)) {
-          this.adapter.onCommentAdded(comment)
-        }
-      }
-      for (id in dirty.comment.removed) {
-        comment = dirty.comment.removed[id]
-        if (!this.isCommentResolved(comment)) {
-          this.adapter.onCommentRemoved(comment)
-        }
-      }
-      for (id in dirty.comment.moved) {
-        comment = dirty.comment.moved[id]
-        if (this.adapter.onCommentMoved && !this.isCommentResolved(comment)) {
-          updateMarkers = true
-          this.adapter.onCommentMoved(comment)
-        }
+    for (id in dirty.comment.added) {
+      comment = dirty.comment.added[id]
+      if (!this.isCommentResolved(comment)) {
+        this.adapter.onCommentAdded(comment)
       }
-
-      /**
-       * For now, if not using ACE don't worry about the markers
-       */
-      if (!this.editor) {
-        updateMarkers = false
+    }
+    for (id in dirty.comment.removed) {
+      comment = dirty.comment.removed[id]
+      if (!this.isCommentResolved(comment)) {
+        this.adapter.onCommentRemoved(comment)
       }
-
-      this.rangesTracker.resetDirtyState()
-      if (updateMarkers) {
-        this.editor.renderer.updateBackMarkers()
+    }
+    for (id in dirty.comment.moved) {
+      comment = dirty.comment.moved[id]
+      if (this.adapter.onCommentMoved && !this.isCommentResolved(comment)) {
+        updateMarkers = true
+        this.adapter.onCommentMoved(comment)
       }
-      this.broadcastChange()
     }
 
-    addComment(offset, content, thread_id) {
-      const op = { c: content, p: offset, t: thread_id }
-      // @rangesTracker.applyOp op # Will apply via sharejs
-      this.$scope.sharejsDoc.submitOp(op)
+    /**
+     * For now, if not using ACE don't worry about the markers
+     */
+    if (!this.editor) {
+      updateMarkers = false
     }
 
-    addCommentToSelection(thread_id, offset, length) {
-      const start = this.adapter.shareJsOffsetToRowColumn(offset)
-      const end = this.adapter.shareJsOffsetToRowColumn(offset + length)
-      const range = new Range(start.row, start.column, end.row, end.column)
-      const content = this.editor.session.getTextRange(range)
-      this.addComment(offset, content, thread_id)
+    this.rangesTracker.resetDirtyState()
+    if (updateMarkers) {
+      this.editor.renderer.updateBackMarkers()
     }
+    this.broadcastChange()
+  }
 
-    isCommentResolved(comment) {
-      return this.rangesTracker.resolvedThreadIds[comment.op.t]
-    }
+  addComment(offset, content, thread_id) {
+    const op = { c: content, p: offset, t: thread_id }
+    // @rangesTracker.applyOp op # Will apply via sharejs
+    this.$scope.sharejsDoc.submitOp(op)
+  }
 
-    selectLineIfNoSelection() {
-      if (this.editor.selection.isEmpty()) {
-        this.editor.selection.selectLine()
-      }
-    }
+  addCommentToSelection(thread_id, offset, length) {
+    const start = this.adapter.shareJsOffsetToRowColumn(offset)
+    const end = this.adapter.shareJsOffsetToRowColumn(offset + length)
+    const range = new Range(start.row, start.column, end.row, end.column)
+    const content = this.editor.session.getTextRange(range)
+    this.addComment(offset, content, thread_id)
+  }
 
-    acceptChangeIds(change_ids) {
-      this.rangesTracker.removeChangeIds(change_ids)
-      this.updateAnnotations()
-      this.updateFocus()
+  isCommentResolved(comment) {
+    return this.rangesTracker.resolvedThreadIds[comment.op.t]
+  }
+
+  selectLineIfNoSelection() {
+    if (this.editor.selection.isEmpty()) {
+      this.editor.selection.selectLine()
     }
+  }
 
-    rejectChangeIds(change_ids) {
-      const changes = this.rangesTracker.getChanges(change_ids)
-      if (changes.length === 0) {
-        return
-      }
+  acceptChangeIds(change_ids) {
+    this.rangesTracker.removeChangeIds(change_ids)
+    this.updateAnnotations()
+    this.updateFocus()
+  }
 
-      // When doing bulk rejections, adjacent changes might interact with each other.
-      // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
-      //
-      //     "foo bar baz" -> "foo quux baz"
-      //
-      // The change above will be modeled with two ops, with the insertion going first:
-      //
-      //     foo quux baz
-      //         |--| -> insertion of "quux", op 1, at position 4
-      //             | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
-      //
-      // When rejecting these changes at once, if the insertion is rejected first, we get unexpected
-      // results. What happens is:
-      //
-      //     1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
-      //        starting from position 4;
-      //
-      //           "foo quux baz" -> "foo  baz"
-      //                |--| -> 4 characters to be removed
-      //
-      //     2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
-      //        the word "quuux" was still present).
-      //
-      //            "foo  baz" -> "foo  bazbar"
-      //                     | -> deletion of "bar" is reverted by reinserting "bar" at position 8
-      //
-      // While the intended result would be "foo bar baz", what we get is:
-      //
-      //      "foo  bazbar" (note "bar" readded at position 8)
-      //
-      // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
-      // from position 4. This includes the position where the deletion exists; when that position is
-      // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
-      // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
-      // does so at the outdated position (position 8, which was valid when "quux" was present).
-      //
-      // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
-      // subsequent operations that come after. Reverse sorting the operations based on position will
-      // achieve it; in the case above, it makes sure that the the deletion is reverted first:
-      //
-      //     1) Rejecting the deletion adds the deleted word "bar" at position 8
-      //
-      //            "foo quux baz" -> "foo quuxbar baz"
-      //                                       | -> deletion of "bar" is reverted by
-      //                                            reinserting "bar" at position 8
-      //
-      //     2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
-      //        starting from position 4 and achieves the expected result:
-      //
-      //           "foo quuxbar baz" -> "foo bar baz"
-      //                |--| -> 4 characters to be removed
-
-      changes.sort((a, b) => b.op.p - a.op.p)
-
-      const session = this.editor.getSession()
-      for (let change of Array.from(changes)) {
-        if (change.op.d != null) {
-          const content = change.op.d
-          const position = this.adapter.shareJsOffsetToRowColumn(change.op.p)
-          session.$fromReject = true // Tell track changes to cancel out delete
-          session.insert(position, content)
-          session.$fromReject = false
-        } else if (change.op.i != null) {
-          const start = this.adapter.shareJsOffsetToRowColumn(change.op.p)
-          const end = this.adapter.shareJsOffsetToRowColumn(
-            change.op.p + change.op.i.length
+  rejectChangeIds(change_ids) {
+    const changes = this.rangesTracker.getChanges(change_ids)
+    if (changes.length === 0) {
+      return
+    }
+
+    // When doing bulk rejections, adjacent changes might interact with each other.
+    // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
+    //
+    //     "foo bar baz" -> "foo quux baz"
+    //
+    // The change above will be modeled with two ops, with the insertion going first:
+    //
+    //     foo quux baz
+    //         |--| -> insertion of "quux", op 1, at position 4
+    //             | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
+    //
+    // When rejecting these changes at once, if the insertion is rejected first, we get unexpected
+    // results. What happens is:
+    //
+    //     1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
+    //        starting from position 4;
+    //
+    //           "foo quux baz" -> "foo  baz"
+    //                |--| -> 4 characters to be removed
+    //
+    //     2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
+    //        the word "quuux" was still present).
+    //
+    //            "foo  baz" -> "foo  bazbar"
+    //                     | -> deletion of "bar" is reverted by reinserting "bar" at position 8
+    //
+    // While the intended result would be "foo bar baz", what we get is:
+    //
+    //      "foo  bazbar" (note "bar" readded at position 8)
+    //
+    // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
+    // from position 4. This includes the position where the deletion exists; when that position is
+    // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
+    // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
+    // does so at the outdated position (position 8, which was valid when "quux" was present).
+    //
+    // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
+    // subsequent operations that come after. Reverse sorting the operations based on position will
+    // achieve it; in the case above, it makes sure that the the deletion is reverted first:
+    //
+    //     1) Rejecting the deletion adds the deleted word "bar" at position 8
+    //
+    //            "foo quux baz" -> "foo quuxbar baz"
+    //                                       | -> deletion of "bar" is reverted by
+    //                                            reinserting "bar" at position 8
+    //
+    //     2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
+    //        starting from position 4 and achieves the expected result:
+    //
+    //           "foo quuxbar baz" -> "foo bar baz"
+    //                |--| -> 4 characters to be removed
+
+    changes.sort((a, b) => b.op.p - a.op.p)
+
+    const session = this.editor.getSession()
+    for (let change of Array.from(changes)) {
+      if (change.op.d != null) {
+        const content = change.op.d
+        const position = this.adapter.shareJsOffsetToRowColumn(change.op.p)
+        session.$fromReject = true // Tell track changes to cancel out delete
+        session.insert(position, content)
+        session.$fromReject = false
+      } else if (change.op.i != null) {
+        const start = this.adapter.shareJsOffsetToRowColumn(change.op.p)
+        const end = this.adapter.shareJsOffsetToRowColumn(
+          change.op.p + change.op.i.length
+        )
+        const editor_text = session.getDocument().getTextRange({ start, end })
+        if (editor_text !== change.op.i) {
+          throw new Error(
+            `Op to be removed (${JSON.stringify(
+              change.op
+            )}), does not match editor text, '${editor_text}'`
           )
-          const editor_text = session.getDocument().getTextRange({ start, end })
-          if (editor_text !== change.op.i) {
-            throw new Error(
-              `Op to be removed (${JSON.stringify(
-                change.op
-              )}), does not match editor text, '${editor_text}'`
-            )
-          }
-          session.$fromReject = true
-          session.remove({ start, end })
-          session.$fromReject = false
-        } else {
-          throw new Error(`unknown change: ${JSON.stringify(change)}`)
         }
+        session.$fromReject = true
+        session.remove({ start, end })
+        session.$fromReject = false
+      } else {
+        throw new Error(`unknown change: ${JSON.stringify(change)}`)
       }
-      setTimeout(() => this.updateFocus())
-    }
-
-    removeCommentId(comment_id) {
-      this.rangesTracker.removeCommentId(comment_id)
-      return this.updateAnnotations()
     }
+    setTimeout(() => this.updateFocus())
+  }
 
-    hideCommentsByThreadIds(thread_ids) {
-      const resolve_ids = {}
-      let comments = this.rangesTracker.comments || []
-      for (let id of Array.from(thread_ids)) {
-        resolve_ids[id] = true
-      }
+  removeCommentId(comment_id) {
+    this.rangesTracker.removeCommentId(comment_id)
+    return this.updateAnnotations()
+  }
 
-      for (let comment of comments) {
-        if (resolve_ids[comment.op.t]) {
-          this.adapter.onCommentRemoved(comment)
-        }
-      }
-      return this.broadcastChange()
+  hideCommentsByThreadIds(thread_ids) {
+    const resolve_ids = {}
+    let comments = this.rangesTracker.comments || []
+    for (let id of Array.from(thread_ids)) {
+      resolve_ids[id] = true
     }
 
-    showCommentByThreadId(thread_id) {
-      let comments = this.rangesTracker.comments || []
-      for (let comment of comments) {
-        if (comment.op.t === thread_id && !this.isCommentResolved(comment)) {
-          this.adapter.onCommentAdded(comment)
-        }
+    for (let comment of comments) {
+      if (resolve_ids[comment.op.t]) {
+        this.adapter.onCommentRemoved(comment)
       }
-      return this.broadcastChange()
-    }
-
-    _resetCutState() {
-      return (this._cutState = {
-        text: null,
-        comments: [],
-        docId: null
-      })
-    }
-
-    onCut() {
-      this._resetCutState()
-      const selection = this.editor.getSelectionRange()
-      const selection_start = this._rangeToShareJs(selection.start)
-      const selection_end = this._rangeToShareJs(selection.end)
-      this._cutState.text = this.editor.getSelectedText()
-      this._cutState.docId = this.$scope.docId
-      return (() => {
-        const result = []
-        for (let comment of Array.from(this.rangesTracker.comments)) {
-          const comment_start = comment.op.p
-          const comment_end = comment_start + comment.op.c.length
-          if (
-            selection_start <= comment_start &&
-            comment_end <= selection_end
-          ) {
-            result.push(
-              this._cutState.comments.push({
-                offset: comment.op.p - selection_start,
-                text: comment.op.c,
-                comment
-              })
-            )
-          } else {
-            result.push(undefined)
-          }
-        }
-        return result
-      })()
-    }
-
-    onPaste() {
-      this.editor.once('change', change => {
-        if (change.action !== 'insert') {
-          return
-        }
-        const pasted_text = change.lines.join('\n')
-        const paste_offset = this._rangeToShareJs(change.start)
-        // We have to wait until the change has been processed by the range
-        // tracker, since if we move the ops into place beforehand, they will be
-        // moved again when the changes are processed by the range tracker. This
-        // ranges:dirty event is fired after the doc has applied the changes to
-        // the range tracker.
-        this.$scope.sharejsDoc.on('ranges:dirty.paste', () => {
-          // Doc event emitter uses namespaced events
-          this.$scope.sharejsDoc.off('ranges:dirty.paste')
-          if (
-            pasted_text === this._cutState.text &&
-            this.$scope.docId === this._cutState.docId
-          ) {
-            for (let { comment, offset, text } of Array.from(
-              this._cutState.comments
-            )) {
-              const op = { c: text, p: paste_offset + offset, t: comment.id }
-              this.$scope.sharejsDoc.submitOp(op)
-            } // Resubmitting an existing comment op (by thread id) will move it
-          }
-          this._resetCutState()
-          // Check that comments still match text. Will throw error if not.
-          this.rangesTracker.validate(this.editor.getValue())
-        })
-      })
     }
+    return this.broadcastChange()
+  }
 
-    checkMapping() {
-      // TODO: reintroduce this check
-      let background_marker_id, callout_marker_id, end, marker, op, start
-      const session = this.editor.getSession()
-
-      // Make a copy of session.getMarkers() so we can modify it
-      const markers = {}
-      const object = session.getMarkers()
-      for (var marker_id in object) {
-        marker = object[marker_id]
-        markers[marker_id] = marker
+  showCommentByThreadId(thread_id) {
+    let comments = this.rangesTracker.comments || []
+    for (let comment of comments) {
+      if (comment.op.t === thread_id && !this.isCommentResolved(comment)) {
+        this.adapter.onCommentAdded(comment)
       }
+    }
+    return this.broadcastChange()
+  }
 
-      const expected_markers = []
-      for (var change of Array.from(this.rangesTracker.changes)) {
-        if (this.adapter.changeIdToMarkerIdMap[change.id] != null) {
-          ;({ op } = change)
-          ;({
-            background_marker_id,
-            callout_marker_id
-          } = this.adapter.changeIdToMarkerIdMap[change.id])
-          start = this.adapter.shareJsOffsetToRowColumn(op.p)
-          if (op.i != null) {
-            end = this.adapter.shareJsOffsetToRowColumn(op.p + op.i.length)
-          } else if (op.d != null) {
-            end = start
-          }
-          expected_markers.push({
-            marker_id: background_marker_id,
-            start,
-            end
-          })
-          expected_markers.push({
-            marker_id: callout_marker_id,
-            start,
-            end: start
-          })
-        }
-      }
+  _resetCutState() {
+    return (this._cutState = {
+      text: null,
+      comments: [],
+      docId: null
+    })
+  }
 
+  onCut() {
+    this._resetCutState()
+    const selection = this.editor.getSelectionRange()
+    const selection_start = this._rangeToShareJs(selection.start)
+    const selection_end = this._rangeToShareJs(selection.end)
+    this._cutState.text = this.editor.getSelectedText()
+    this._cutState.docId = this.$scope.docId
+    return (() => {
+      const result = []
       for (let comment of Array.from(this.rangesTracker.comments)) {
-        if (this.adapter.changeIdToMarkerIdMap[comment.id] != null) {
-          ;({
-            background_marker_id,
-            callout_marker_id
-          } = this.adapter.changeIdToMarkerIdMap[comment.id])
-          start = this.adapter.shareJsOffsetToRowColumn(comment.op.p)
-          end = this.adapter.shareJsOffsetToRowColumn(
-            comment.op.p + comment.op.c.length
+        const comment_start = comment.op.p
+        const comment_end = comment_start + comment.op.c.length
+        if (selection_start <= comment_start && comment_end <= selection_end) {
+          result.push(
+            this._cutState.comments.push({
+              offset: comment.op.p - selection_start,
+              text: comment.op.c,
+              comment
+            })
           )
-          expected_markers.push({
-            marker_id: background_marker_id,
-            start,
-            end
-          })
-          expected_markers.push({
-            marker_id: callout_marker_id,
-            start,
-            end: start
-          })
+        } else {
+          result.push(undefined)
         }
       }
+      return result
+    })()
+  }
 
-      for ({ marker_id, start, end } of Array.from(expected_markers)) {
-        marker = markers[marker_id]
-        delete markers[marker_id]
+  onPaste() {
+    this.editor.once('change', change => {
+      if (change.action !== 'insert') {
+        return
+      }
+      const pasted_text = change.lines.join('\n')
+      const paste_offset = this._rangeToShareJs(change.start)
+      // We have to wait until the change has been processed by the range
+      // tracker, since if we move the ops into place beforehand, they will be
+      // moved again when the changes are processed by the range tracker. This
+      // ranges:dirty event is fired after the doc has applied the changes to
+      // the range tracker.
+      this.$scope.sharejsDoc.on('ranges:dirty.paste', () => {
+        // Doc event emitter uses namespaced events
+        this.$scope.sharejsDoc.off('ranges:dirty.paste')
         if (
-          marker.range.start.row !== start.row ||
-          marker.range.start.column !== start.column ||
-          marker.range.end.row !== end.row ||
-          marker.range.end.column !== end.column
+          pasted_text === this._cutState.text &&
+          this.$scope.docId === this._cutState.docId
         ) {
-          console.error("Change doesn't match marker anymore", {
-            change,
-            marker,
-            start,
-            end
-          })
+          for (let { comment, offset, text } of Array.from(
+            this._cutState.comments
+          )) {
+            const op = { c: text, p: paste_offset + offset, t: comment.id }
+            this.$scope.sharejsDoc.submitOp(op)
+          } // Resubmitting an existing comment op (by thread id) will move it
         }
-      }
+        this._resetCutState()
+        // Check that comments still match text. Will throw error if not.
+        this.rangesTracker.validate(this.editor.getValue())
+      })
+    })
+  }
 
-      return (() => {
-        const result = []
-        for (marker_id in markers) {
-          marker = markers[marker_id]
-          if (/track-changes/.test(marker.clazz)) {
-            result.push(console.error('Orphaned ace marker', marker))
-          } else {
-            result.push(undefined)
-          }
+  checkMapping() {
+    // TODO: reintroduce this check
+    let background_marker_id, callout_marker_id, end, marker, op, start
+    const session = this.editor.getSession()
+
+    // Make a copy of session.getMarkers() so we can modify it
+    const markers = {}
+    const object = session.getMarkers()
+    for (var marker_id in object) {
+      marker = object[marker_id]
+      markers[marker_id] = marker
+    }
+
+    const expected_markers = []
+    for (var change of Array.from(this.rangesTracker.changes)) {
+      if (this.adapter.changeIdToMarkerIdMap[change.id] != null) {
+        ;({ op } = change)
+        ;({
+          background_marker_id,
+          callout_marker_id
+        } = this.adapter.changeIdToMarkerIdMap[change.id])
+        start = this.adapter.shareJsOffsetToRowColumn(op.p)
+        if (op.i != null) {
+          end = this.adapter.shareJsOffsetToRowColumn(op.p + op.i.length)
+        } else if (op.d != null) {
+          end = start
         }
-        return result
-      })()
+        expected_markers.push({
+          marker_id: background_marker_id,
+          start,
+          end
+        })
+        expected_markers.push({
+          marker_id: callout_marker_id,
+          start,
+          end: start
+        })
+      }
     }
 
-    broadcastChange() {
-      this.$scope.$emit('editor:track-changes:changed', this.$scope.docId)
+    for (let comment of Array.from(this.rangesTracker.comments)) {
+      if (this.adapter.changeIdToMarkerIdMap[comment.id] != null) {
+        ;({
+          background_marker_id,
+          callout_marker_id
+        } = this.adapter.changeIdToMarkerIdMap[comment.id])
+        start = this.adapter.shareJsOffsetToRowColumn(comment.op.p)
+        end = this.adapter.shareJsOffsetToRowColumn(
+          comment.op.p + comment.op.c.length
+        )
+        expected_markers.push({
+          marker_id: background_marker_id,
+          start,
+          end
+        })
+        expected_markers.push({
+          marker_id: callout_marker_id,
+          start,
+          end: start
+        })
+      }
     }
 
-    recalculateReviewEntriesScreenPositions() {
-      const session = this.editor.getSession()
-      const { renderer } = this.editor
-      const entries = this._getCurrentDocEntries()
-      const object = entries || {}
-      for (let entry_id in object) {
-        const entry = object[entry_id]
-        const doc_position = this.adapter.shareJsOffsetToRowColumn(entry.offset)
-        const screen_position = session.documentToScreenPosition(
-          doc_position.row,
-          doc_position.column
-        )
-        const y = screen_position.row * renderer.lineHeight
-        if (entry.screenPos == null) {
-          entry.screenPos = {}
-        }
-        entry.screenPos.y = y
-        entry.docPos = doc_position
+    for ({ marker_id, start, end } of Array.from(expected_markers)) {
+      marker = markers[marker_id]
+      delete markers[marker_id]
+      if (
+        marker.range.start.row !== start.row ||
+        marker.range.start.column !== start.column ||
+        marker.range.end.row !== end.row ||
+        marker.range.end.column !== end.column
+      ) {
+        console.error("Change doesn't match marker anymore", {
+          change,
+          marker,
+          start,
+          end
+        })
       }
-      this.recalculateVisibleEntries()
-      this.$scope.$apply()
     }
 
-    recalculateVisibleEntries() {
-      const OFFSCREEN_ROWS = 20
-
-      // With less than this number of entries, don't bother culling to avoid
-      // little UI jumps when scrolling.
-      const CULL_AFTER = 100
-
-      const { firstRow, lastRow } = this.editor.renderer.layerConfig
-      const entries = this._getCurrentDocEntries() || {}
-      const entriesLength = Object.keys(entries).length
-      let changed = false
-      for (let entry_id in entries) {
-        const entry = entries[entry_id]
-        const old = entry.visible
-        entry.visible =
-          entriesLength < CULL_AFTER ||
-          (firstRow - OFFSCREEN_ROWS <= entry.docPos.row &&
-            entry.docPos.row <= lastRow + OFFSCREEN_ROWS)
-        if (entry.visible !== old) {
-          changed = true
+    return (() => {
+      const result = []
+      for (marker_id in markers) {
+        marker = markers[marker_id]
+        if (/track-changes/.test(marker.clazz)) {
+          result.push(console.error('Orphaned ace marker', marker))
+        } else {
+          result.push(undefined)
         }
       }
-      if (changed) {
-        this.$scope.$emit('editor:track-changes:visibility_changed')
-      }
-    }
+      return result
+    })()
+  }
 
-    _getCurrentDocEntries() {
-      const doc_id = this.$scope.docId
-      const entries = this.$scope.reviewPanel.entries[doc_id] || {}
-      return entries
-    }
+  broadcastChange() {
+    this.$scope.$emit('editor:track-changes:changed', this.$scope.docId)
+  }
 
-    updateFocus() {
-      if (this.editor) {
-        const selection = this.editor.getSelectionRange()
-        const selection_start = this._rangeToShareJs(selection.start)
-        const selection_end = this._rangeToShareJs(selection.end)
-        const is_selection = selection_start !== selection_end
-        this.$scope.$emit(
-          'editor:focus:changed',
-          selection_start,
-          selection_end,
-          is_selection
-        )
+  recalculateReviewEntriesScreenPositions() {
+    const session = this.editor.getSession()
+    const { renderer } = this.editor
+    const entries = this._getCurrentDocEntries()
+    const object = entries || {}
+    for (let entry_id in object) {
+      const entry = object[entry_id]
+      const doc_position = this.adapter.shareJsOffsetToRowColumn(entry.offset)
+      const screen_position = session.documentToScreenPosition(
+        doc_position.row,
+        doc_position.column
+      )
+      const y = screen_position.row * renderer.lineHeight
+      if (entry.screenPos == null) {
+        entry.screenPos = {}
       }
+      entry.screenPos.y = y
+      entry.docPos = doc_position
     }
+    this.recalculateVisibleEntries()
+    this.$scope.$apply()
+  }
 
-    _rangeToShareJs(range) {
-      const lines = this.editor
-        .getSession()
-        .getDocument()
-        .getLines(0, range.row)
-      return EditorShareJsCodec.rangeToShareJs(range, lines)
+  recalculateVisibleEntries() {
+    const OFFSCREEN_ROWS = 20
+
+    // With less than this number of entries, don't bother culling to avoid
+    // little UI jumps when scrolling.
+    const CULL_AFTER = 100
+
+    const { firstRow, lastRow } = this.editor.renderer.layerConfig
+    const entries = this._getCurrentDocEntries() || {}
+    const entriesLength = Object.keys(entries).length
+    let changed = false
+    for (let entry_id in entries) {
+      const entry = entries[entry_id]
+      const old = entry.visible
+      entry.visible =
+        entriesLength < CULL_AFTER ||
+        (firstRow - OFFSCREEN_ROWS <= entry.docPos.row &&
+          entry.docPos.row <= lastRow + OFFSCREEN_ROWS)
+      if (entry.visible !== old) {
+        changed = true
+      }
+    }
+    if (changed) {
+      this.$scope.$emit('editor:track-changes:visibility_changed')
     }
+  }
+
+  _getCurrentDocEntries() {
+    const doc_id = this.$scope.docId
+    const entries = this.$scope.reviewPanel.entries[doc_id] || {}
+    return entries
+  }
 
-    _changeToShareJs(delta) {
-      const lines = this.editor
-        .getSession()
-        .getDocument()
-        .getLines(0, delta.start.row)
-      return EditorShareJsCodec.changeToShareJs(delta, lines)
+  updateFocus() {
+    if (this.editor) {
+      const selection = this.editor.getSelectionRange()
+      const selection_start = this._rangeToShareJs(selection.start)
+      const selection_end = this._rangeToShareJs(selection.end)
+      const is_selection = selection_start !== selection_end
+      this.$scope.$emit(
+        'editor:focus:changed',
+        selection_start,
+        selection_end,
+        is_selection
+      )
     }
   }
-  return TrackChangesManager
-})
+
+  _rangeToShareJs(range) {
+    const lines = this.editor
+      .getSession()
+      .getDocument()
+      .getLines(0, range.row)
+    return EditorShareJsCodec.rangeToShareJs(range, lines)
+  }
+
+  _changeToShareJs(delta) {
+    const lines = this.editor
+      .getSession()
+      .getDocument()
+      .getLines(0, delta.start.row)
+    return EditorShareJsCodec.changeToShareJs(delta, lines)
+  }
+}
+export default TrackChangesManager

+ 26 - 27
services/web/frontend/js/ide/editor/directives/aceEditor/undo/UndoManager.js

@@ -1,34 +1,33 @@
-define(['ace/ace'], function() {
-  const BuiltInUndoManager = ace.require('ace/undomanager').UndoManager
+import 'ace/ace'
+const BuiltInUndoManager = ace.require('ace/undomanager').UndoManager
 
-  class UndoManager {
-    constructor(editor) {
-      this.editor = editor
+class UndoManager {
+  constructor(editor) {
+    this.editor = editor
 
-      this.onChangeSession = this.onChangeSession.bind(this)
-      this.onChange = this.onChange.bind(this)
-    }
+    this.onChangeSession = this.onChangeSession.bind(this)
+    this.onChange = this.onChange.bind(this)
+  }
 
-    onChangeSession(session) {
-      session.setUndoManager(new BuiltInUndoManager())
-    }
+  onChangeSession(session) {
+    session.setUndoManager(new BuiltInUndoManager())
+  }
 
-    onChange(change) {
-      if (!change.remote) return
+  onChange(change) {
+    if (!change.remote) return
 
-      // HACK: remote changes in Ace are added by the ShareJS/Ace adapter
-      // asynchronously via a timeout (see attach_ace function). This makes it
-      // impossible to clear to undo stack when remote changes are received.
-      // To hack around this we queue the undo stack clear so that it applies
-      // after the change is applied
-      setTimeout(() => {
-        this.editor
-          .getSession()
-          .getUndoManager()
-          .reset()
-      })
-    }
+    // HACK: remote changes in Ace are added by the ShareJS/Ace adapter
+    // asynchronously via a timeout (see attach_ace function). This makes it
+    // impossible to clear to undo stack when remote changes are received.
+    // To hack around this we queue the undo stack clear so that it applies
+    // after the change is applied
+    setTimeout(() => {
+      this.editor
+        .getSession()
+        .getUndoManager()
+        .reset()
+    })
   }
+}
 
-  return UndoManager
-})
+export default UndoManager

+ 32 - 31
services/web/frontend/js/ide/editor/directives/toggleSwitch.js

@@ -8,40 +8,41 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.directive('toggleSwitch', () => ({
-    restrict: 'E',
-    scope: {
-      description: '@',
-      labelFalse: '@',
-      labelTrue: '@',
-      ngModel: '='
-    },
-    template: `\
+import App from '../../../base'
+
+export default App.directive('toggleSwitch', () => ({
+  restrict: 'E',
+  scope: {
+    description: '@',
+    labelFalse: '@',
+    labelTrue: '@',
+    ngModel: '='
+  },
+  template: `\
 <fieldset class="toggle-switch">
-    <legend class="sr-only">{{description}}</legend>
+  <legend class="sr-only">{{description}}</legend>
 
-    <input
-      type="radio"
-      name="toggle-switch-{{$id}}"
-      class="toggle-switch-input"
-      id="toggle-switch-false-{{$id}}"
-      ng-value="false"
-      ng-model="ngModel"
-    >
-    <label for="toggle-switch-false-{{$id}}" class="toggle-switch-label">{{labelFalse}}</label>
+  <input
+    type="radio"
+    name="toggle-switch-{{$id}}"
+    class="toggle-switch-input"
+    id="toggle-switch-false-{{$id}}"
+    ng-value="false"
+    ng-model="ngModel"
+  >
+  <label for="toggle-switch-false-{{$id}}" class="toggle-switch-label">{{labelFalse}}</label>
 
-    <input
-      type="radio"
-      class="toggle-switch-input"
-      name="toggle-switch-{{$id}}"
-      id="toggle-switch-true-{{$id}}"
-      ng-value="true"
-      ng-model="ngModel"
-    >
-    <label for="toggle-switch-true-{{$id}}" class="toggle-switch-label">{{labelTrue}}</label>
+  <input
+    type="radio"
+    class="toggle-switch-input"
+    name="toggle-switch-{{$id}}"
+    id="toggle-switch-true-{{$id}}"
+    ng-value="true"
+    ng-model="ngModel"
+  >
+  <label for="toggle-switch-true-{{$id}}" class="toggle-switch-label">{{labelTrue}}</label>
 
-    <span class="toggle-switch-selection" aria-hidden="true"></span>
+  <span class="toggle-switch-selection" aria-hidden="true"></span>
 </fieldset>\
 `
-  })))
+}))

+ 611 - 618
services/web/frontend/js/ide/file-tree/FileTreeManager.js

@@ -18,741 +18,734 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  './directives/fileEntity',
-  './directives/draggable',
-  './directives/droppable',
-  './controllers/FileTreeController',
-  './controllers/FileTreeEntityController',
-  './controllers/FileTreeFolderController',
-  './controllers/FileTreeRootFolderController'
-], function() {
-  let FileTreeManager
-  return (FileTreeManager = class FileTreeManager {
-    constructor(ide, $scope) {
-      this.ide = ide
-      this.$scope = $scope
-      this.$scope.$on('project:joined', () => {
-        this.loadRootFolder()
-        this.loadDeletedDocs()
-        return this.$scope.$emit('file-tree:initialized')
-      })
-
-      this.$scope.$watch('rootFolder', rootFolder => {
-        if (rootFolder != null) {
-          return this.recalculateDocList()
-        }
-      })
-
-      this._bindToSocketEvents()
-
-      this.$scope.multiSelectedCount = 0
-
-      $(document).on('click', () => {
-        this.clearMultiSelectedEntities()
-        return this.$scope.$digest()
-      })
-    }
-
-    _bindToSocketEvents() {
-      this.ide.socket.on('reciveNewDoc', (parent_folder_id, doc) => {
-        const parent_folder =
-          this.findEntityById(parent_folder_id) || this.$scope.rootFolder
-        return this.$scope.$apply(() => {
-          parent_folder.children.push({
-            name: doc.name,
-            id: doc._id,
-            type: 'doc'
-          })
-          return this.recalculateDocList()
+import './directives/fileEntity'
+import './directives/draggable'
+import './directives/droppable'
+import './controllers/FileTreeController'
+import './controllers/FileTreeEntityController'
+import './controllers/FileTreeFolderController'
+import './controllers/FileTreeRootFolderController'
+let FileTreeManager
+
+export default (FileTreeManager = class FileTreeManager {
+  constructor(ide, $scope) {
+    this.ide = ide
+    this.$scope = $scope
+    this.$scope.$on('project:joined', () => {
+      this.loadRootFolder()
+      this.loadDeletedDocs()
+      return this.$scope.$emit('file-tree:initialized')
+    })
+
+    this.$scope.$watch('rootFolder', rootFolder => {
+      if (rootFolder != null) {
+        return this.recalculateDocList()
+      }
+    })
+
+    this._bindToSocketEvents()
+
+    this.$scope.multiSelectedCount = 0
+
+    $(document).on('click', () => {
+      this.clearMultiSelectedEntities()
+      return this.$scope.$digest()
+    })
+  }
+
+  _bindToSocketEvents() {
+    this.ide.socket.on('reciveNewDoc', (parent_folder_id, doc) => {
+      const parent_folder =
+        this.findEntityById(parent_folder_id) || this.$scope.rootFolder
+      return this.$scope.$apply(() => {
+        parent_folder.children.push({
+          name: doc.name,
+          id: doc._id,
+          type: 'doc'
         })
+        return this.recalculateDocList()
       })
+    })
 
-      this.ide.socket.on(
-        'reciveNewFile',
-        (parent_folder_id, file, source, linkedFileData) => {
-          const parent_folder =
-            this.findEntityById(parent_folder_id) || this.$scope.rootFolder
-          return this.$scope.$apply(() => {
-            parent_folder.children.push({
-              name: file.name,
-              id: file._id,
-              type: 'file',
-              linkedFileData,
-              created: file.created
-            })
-            return this.recalculateDocList()
-          })
-        }
-      )
-
-      this.ide.socket.on('reciveNewFolder', (parent_folder_id, folder) => {
+    this.ide.socket.on(
+      'reciveNewFile',
+      (parent_folder_id, file, source, linkedFileData) => {
         const parent_folder =
           this.findEntityById(parent_folder_id) || this.$scope.rootFolder
         return this.$scope.$apply(() => {
           parent_folder.children.push({
-            name: folder.name,
-            id: folder._id,
-            type: 'folder',
-            children: []
+            name: file.name,
+            id: file._id,
+            type: 'file',
+            linkedFileData,
+            created: file.created
           })
           return this.recalculateDocList()
         })
-      })
-
-      this.ide.socket.on('reciveEntityRename', (entity_id, name) => {
-        const entity = this.findEntityById(entity_id)
-        if (entity == null) {
-          return
-        }
-        return this.$scope.$apply(() => {
-          entity.name = name
-          return this.recalculateDocList()
-        })
-      })
-
-      this.ide.socket.on('removeEntity', entity_id => {
-        const entity = this.findEntityById(entity_id)
-        if (entity == null) {
-          return
-        }
-        this.$scope.$apply(() => {
-          this._deleteEntityFromScope(entity)
-          return this.recalculateDocList()
-        })
-        return this.$scope.$broadcast('entity:deleted', entity)
-      })
-
-      return this.ide.socket.on('reciveEntityMove', (entity_id, folder_id) => {
-        const entity = this.findEntityById(entity_id)
-        const folder = this.findEntityById(folder_id)
-        return this.$scope.$apply(() => {
-          this._moveEntityInScope(entity, folder)
-          return this.recalculateDocList()
+      }
+    )
+
+    this.ide.socket.on('reciveNewFolder', (parent_folder_id, folder) => {
+      const parent_folder =
+        this.findEntityById(parent_folder_id) || this.$scope.rootFolder
+      return this.$scope.$apply(() => {
+        parent_folder.children.push({
+          name: folder.name,
+          id: folder._id,
+          type: 'folder',
+          children: []
         })
+        return this.recalculateDocList()
       })
-    }
+    })
 
-    selectEntity(entity) {
-      this.selected_entity_id = entity.id // For reselecting after a reconnect
-      this.ide.fileTreeManager.forEachEntity(
-        entity => (entity.selected = false)
-      )
-      return (entity.selected = true)
-    }
-
-    toggleMultiSelectEntity(entity) {
-      entity.multiSelected = !entity.multiSelected
-      return (this.$scope.multiSelectedCount = this.multiSelectedCount())
-    }
-
-    multiSelectedCount() {
-      let count = 0
-      this.forEachEntity(function(entity) {
-        if (entity.multiSelected) {
-          return count++
-        }
+    this.ide.socket.on('reciveEntityRename', (entity_id, name) => {
+      const entity = this.findEntityById(entity_id)
+      if (entity == null) {
+        return
+      }
+      return this.$scope.$apply(() => {
+        entity.name = name
+        return this.recalculateDocList()
       })
-      return count
-    }
+    })
 
-    getMultiSelectedEntities() {
-      const entities = []
-      this.forEachEntity(function(e) {
-        if (e.multiSelected) {
-          return entities.push(e)
-        }
+    this.ide.socket.on('removeEntity', entity_id => {
+      const entity = this.findEntityById(entity_id)
+      if (entity == null) {
+        return
+      }
+      this.$scope.$apply(() => {
+        this._deleteEntityFromScope(entity)
+        return this.recalculateDocList()
       })
-      return entities
-    }
+      return this.$scope.$broadcast('entity:deleted', entity)
+    })
 
-    getFullCount() {
-      const entities = []
-      this.forEachEntity(function(e) {
-        if (!e.deleted) entities.push(e)
+    return this.ide.socket.on('reciveEntityMove', (entity_id, folder_id) => {
+      const entity = this.findEntityById(entity_id)
+      const folder = this.findEntityById(folder_id)
+      return this.$scope.$apply(() => {
+        this._moveEntityInScope(entity, folder)
+        return this.recalculateDocList()
       })
-      return entities.length
-    }
-
-    getMultiSelectedEntityChildNodes() {
-      // use pathnames with a leading slash to avoid
-      // problems with reserved Object properties
-      const entities = this.getMultiSelectedEntities()
-      const paths = {}
-      for (var entity of Array.from(entities)) {
-        paths['/' + this.getEntityPath(entity)] = entity
-      }
-      const prefixes = {}
-      for (var path in paths) {
-        entity = paths[path]
-        const parts = path.split('/')
-        if (parts.length <= 2) {
-          continue
-        } else {
-          // Record prefixes a/b/c.tex -> 'a' and 'a/b'
-          for (
-            let i = 1, end = parts.length - 1, asc = end >= 1;
-            asc ? i <= end : i >= end;
-            asc ? i++ : i--
-          ) {
-            prefixes['/' + parts.slice(0, i).join('/')] = true
-          }
-        }
-      }
-      const child_entities = []
-      for (path in paths) {
-        // If the path is in the prefixes, then it's a parent folder and
-        // should be ignore
-        entity = paths[path]
-        if (prefixes[path] == null) {
-          child_entities.push(entity)
+    })
+  }
+
+  selectEntity(entity) {
+    this.selected_entity_id = entity.id // For reselecting after a reconnect
+    this.ide.fileTreeManager.forEachEntity(entity => (entity.selected = false))
+    return (entity.selected = true)
+  }
+
+  toggleMultiSelectEntity(entity) {
+    entity.multiSelected = !entity.multiSelected
+    return (this.$scope.multiSelectedCount = this.multiSelectedCount())
+  }
+
+  multiSelectedCount() {
+    let count = 0
+    this.forEachEntity(function(entity) {
+      if (entity.multiSelected) {
+        return count++
+      }
+    })
+    return count
+  }
+
+  getMultiSelectedEntities() {
+    const entities = []
+    this.forEachEntity(function(e) {
+      if (e.multiSelected) {
+        return entities.push(e)
+      }
+    })
+    return entities
+  }
+
+  getFullCount() {
+    const entities = []
+    this.forEachEntity(function(e) {
+      if (!e.deleted) entities.push(e)
+    })
+    return entities.length
+  }
+
+  getMultiSelectedEntityChildNodes() {
+    // use pathnames with a leading slash to avoid
+    // problems with reserved Object properties
+    const entities = this.getMultiSelectedEntities()
+    const paths = {}
+    for (var entity of Array.from(entities)) {
+      paths['/' + this.getEntityPath(entity)] = entity
+    }
+    const prefixes = {}
+    for (var path in paths) {
+      entity = paths[path]
+      const parts = path.split('/')
+      if (parts.length <= 2) {
+        continue
+      } else {
+        // Record prefixes a/b/c.tex -> 'a' and 'a/b'
+        for (
+          let i = 1, end = parts.length - 1, asc = end >= 1;
+          asc ? i <= end : i >= end;
+          asc ? i++ : i--
+        ) {
+          prefixes['/' + parts.slice(0, i).join('/')] = true
         }
       }
-      return child_entities
     }
-
-    clearMultiSelectedEntities() {
-      if (this.$scope.multiSelectedCount === 0) {
-        return
-      } // Be efficient, this is called a lot on 'click'
-      this.forEachEntity(entity => (entity.multiSelected = false))
-      return (this.$scope.multiSelectedCount = 0)
-    }
-
-    multiSelectSelectedEntity() {
-      const entity = this.findSelectedEntity()
-      if (entity) {
-        entity.multiSelected = true
+    const child_entities = []
+    for (path in paths) {
+      // If the path is in the prefixes, then it's a parent folder and
+      // should be ignore
+      entity = paths[path]
+      if (prefixes[path] == null) {
+        child_entities.push(entity)
       }
-      this.$scope.multiSelectedCount = this.multiSelectedCount()
     }
+    return child_entities
+  }
 
-    existsInFolder(folder_id, name) {
-      const folder = this.findEntityById(folder_id)
-      if (folder == null) {
-        return false
-      }
-      const entity = this._findEntityByPathInFolder(folder, name)
-      return entity != null
-    }
+  clearMultiSelectedEntities() {
+    if (this.$scope.multiSelectedCount === 0) {
+      return
+    } // Be efficient, this is called a lot on 'click'
+    this.forEachEntity(entity => (entity.multiSelected = false))
+    return (this.$scope.multiSelectedCount = 0)
+  }
 
-    findSelectedEntity() {
-      let selected = null
-      this.forEachEntity(function(entity) {
-        if (entity.selected) {
-          return (selected = entity)
-        }
-      })
-      return selected
+  multiSelectSelectedEntity() {
+    const entity = this.findSelectedEntity()
+    if (entity) {
+      entity.multiSelected = true
     }
+    this.$scope.multiSelectedCount = this.multiSelectedCount()
+  }
 
-    findEntityById(id, options) {
-      if (options == null) {
-        options = {}
-      }
-      if (this.$scope.rootFolder.id === id) {
-        return this.$scope.rootFolder
-      }
+  existsInFolder(folder_id, name) {
+    const folder = this.findEntityById(folder_id)
+    if (folder == null) {
+      return false
+    }
+    const entity = this._findEntityByPathInFolder(folder, name)
+    return entity != null
+  }
 
-      let entity = this._findEntityByIdInFolder(this.$scope.rootFolder, id)
-      if (entity != null) {
-        return entity
+  findSelectedEntity() {
+    let selected = null
+    this.forEachEntity(function(entity) {
+      if (entity.selected) {
+        return (selected = entity)
       }
+    })
+    return selected
+  }
 
-      if (options.includeDeleted) {
-        for (entity of Array.from(this.$scope.deletedDocs)) {
-          if (entity.id === id) {
-            return entity
-          }
-        }
-      }
+  findEntityById(id, options) {
+    if (options == null) {
+      options = {}
+    }
+    if (this.$scope.rootFolder.id === id) {
+      return this.$scope.rootFolder
+    }
 
-      return null
+    let entity = this._findEntityByIdInFolder(this.$scope.rootFolder, id)
+    if (entity != null) {
+      return entity
     }
 
-    _findEntityByIdInFolder(folder, id) {
-      for (let entity of Array.from(folder.children || [])) {
+    if (options.includeDeleted) {
+      for (entity of Array.from(this.$scope.deletedDocs)) {
         if (entity.id === id) {
           return entity
-        } else if (entity.children != null) {
-          const result = this._findEntityByIdInFolder(entity, id)
-          if (result != null) {
-            return result
-          }
         }
       }
-
-      return null
     }
 
-    findEntityByPath(path) {
-      return this._findEntityByPathInFolder(this.$scope.rootFolder, path)
-    }
-
-    _findEntityByPathInFolder(folder, path) {
-      if (path == null || folder == null) {
-        return null
-      }
-      if (path === '') {
-        return folder
-      }
-
-      const parts = path.split('/')
-      const name = parts.shift()
-      const rest = parts.join('/')
+    return null
+  }
 
-      if (name === '.') {
-        return this._findEntityByPathInFolder(folder, rest)
-      }
-
-      for (let entity of Array.from(folder.children)) {
-        if (entity.name === name) {
-          if (rest === '') {
-            return entity
-          } else if (entity.type === 'folder') {
-            return this._findEntityByPathInFolder(entity, rest)
-          }
+  _findEntityByIdInFolder(folder, id) {
+    for (let entity of Array.from(folder.children || [])) {
+      if (entity.id === id) {
+        return entity
+      } else if (entity.children != null) {
+        const result = this._findEntityByIdInFolder(entity, id)
+        if (result != null) {
+          return result
         }
       }
-      return null
     }
 
-    forEachEntity(callback) {
-      if (callback == null) {
-        callback = function(entity, parent_folder, path) {}
-      }
-      this._forEachEntityInFolder(this.$scope.rootFolder, null, callback)
+    return null
+  }
 
-      return (() => {
-        const result = []
-        for (let entity of Array.from(this.$scope.deletedDocs || [])) {
-          result.push(callback(entity))
-        }
-        return result
-      })()
-    }
-
-    _forEachEntityInFolder(folder, path, callback) {
-      return (() => {
-        const result = []
-        for (let entity of Array.from(folder.children || [])) {
-          var childPath
-          if (path != null) {
-            childPath = path + '/' + entity.name
-          } else {
-            childPath = entity.name
-          }
-          callback(entity, folder, childPath)
-          if (entity.children != null) {
-            result.push(
-              this._forEachEntityInFolder(entity, childPath, callback)
-            )
-          } else {
-            result.push(undefined)
-          }
-        }
-        return result
-      })()
-    }
+  findEntityByPath(path) {
+    return this._findEntityByPathInFolder(this.$scope.rootFolder, path)
+  }
 
-    getEntityPath(entity) {
-      return this._getEntityPathInFolder(this.$scope.rootFolder, entity)
-    }
-
-    _getEntityPathInFolder(folder, entity) {
-      for (let child of Array.from(folder.children || [])) {
-        if (child === entity) {
-          return entity.name
-        } else if (child.type === 'folder') {
-          const path = this._getEntityPathInFolder(child, entity)
-          if (path != null) {
-            return child.name + '/' + path
-          }
-        }
-      }
+  _findEntityByPathInFolder(folder, path) {
+    if (path == null || folder == null) {
       return null
     }
-
-    getRootDocDirname() {
-      const rootDoc = this.findEntityById(this.$scope.project.rootDoc_id)
-      if (rootDoc == null) {
-        return
-      }
-      return this._getEntityDirname(rootDoc)
+    if (path === '') {
+      return folder
     }
 
-    _getEntityDirname(entity) {
-      const path = this.getEntityPath(entity)
-      if (path == null) {
-        return
-      }
-      return path
-        .split('/')
-        .slice(0, -1)
-        .join('/')
+    const parts = path.split('/')
+    const name = parts.shift()
+    const rest = parts.join('/')
+
+    if (name === '.') {
+      return this._findEntityByPathInFolder(folder, rest)
     }
 
-    _findParentFolder(entity) {
-      const dirname = this._getEntityDirname(entity)
-      if (dirname == null) {
-        return
+    for (let entity of Array.from(folder.children)) {
+      if (entity.name === name) {
+        if (rest === '') {
+          return entity
+        } else if (entity.type === 'folder') {
+          return this._findEntityByPathInFolder(entity, rest)
+        }
       }
-      return this.findEntityByPath(dirname)
     }
+    return null
+  }
 
-    loadRootFolder() {
-      return (this.$scope.rootFolder = this._parseFolder(
-        __guard__(
-          this.$scope != null ? this.$scope.project : undefined,
-          x => x.rootFolder[0]
-        )
-      ))
+  forEachEntity(callback) {
+    if (callback == null) {
+      callback = function(entity, parent_folder, path) {}
     }
+    this._forEachEntityInFolder(this.$scope.rootFolder, null, callback)
 
-    _parseFolder(rawFolder) {
-      const folder = {
-        name: rawFolder.name,
-        id: rawFolder._id,
-        type: 'folder',
-        children: [],
-        selected: rawFolder._id === this.selected_entity_id
+    return (() => {
+      const result = []
+      for (let entity of Array.from(this.$scope.deletedDocs || [])) {
+        result.push(callback(entity))
       }
+      return result
+    })()
+  }
 
-      for (let doc of Array.from(rawFolder.docs || [])) {
-        folder.children.push({
-          name: doc.name,
-          id: doc._id,
-          type: 'doc',
-          selected: doc._id === this.selected_entity_id
-        })
+  _forEachEntityInFolder(folder, path, callback) {
+    return (() => {
+      const result = []
+      for (let entity of Array.from(folder.children || [])) {
+        var childPath
+        if (path != null) {
+          childPath = path + '/' + entity.name
+        } else {
+          childPath = entity.name
+        }
+        callback(entity, folder, childPath)
+        if (entity.children != null) {
+          result.push(this._forEachEntityInFolder(entity, childPath, callback))
+        } else {
+          result.push(undefined)
+        }
       }
-
-      for (let file of Array.from(rawFolder.fileRefs || [])) {
-        folder.children.push({
-          name: file.name,
-          id: file._id,
-          type: 'file',
-          selected: file._id === this.selected_entity_id,
-          linkedFileData: file.linkedFileData,
-          created: file.created
-        })
+      return result
+    })()
+  }
+
+  getEntityPath(entity) {
+    return this._getEntityPathInFolder(this.$scope.rootFolder, entity)
+  }
+
+  _getEntityPathInFolder(folder, entity) {
+    for (let child of Array.from(folder.children || [])) {
+      if (child === entity) {
+        return entity.name
+      } else if (child.type === 'folder') {
+        const path = this._getEntityPathInFolder(child, entity)
+        if (path != null) {
+          return child.name + '/' + path
+        }
       }
+    }
+    return null
+  }
 
-      for (let childFolder of Array.from(rawFolder.folders || [])) {
-        folder.children.push(this._parseFolder(childFolder))
-      }
+  getRootDocDirname() {
+    const rootDoc = this.findEntityById(this.$scope.project.rootDoc_id)
+    if (rootDoc == null) {
+      return
+    }
+    return this._getEntityDirname(rootDoc)
+  }
 
-      return folder
+  _getEntityDirname(entity) {
+    const path = this.getEntityPath(entity)
+    if (path == null) {
+      return
     }
+    return path
+      .split('/')
+      .slice(0, -1)
+      .join('/')
+  }
 
-    loadDeletedDocs() {
-      this.$scope.deletedDocs = []
-      return Array.from(this.$scope.project.deletedDocs || []).map(doc =>
-        this.$scope.deletedDocs.push({
-          name: doc.name,
-          id: doc._id,
-          type: 'doc',
-          deleted: true
-        })
-      )
+  _findParentFolder(entity) {
+    const dirname = this._getEntityDirname(entity)
+    if (dirname == null) {
+      return
     }
+    return this.findEntityByPath(dirname)
+  }
 
-    recalculateDocList() {
-      this.$scope.docs = []
-      this.forEachEntity((entity, parentFolder, path) => {
-        if (entity.type === 'doc' && !entity.deleted) {
-          return this.$scope.docs.push({
-            doc: entity,
-            path
-          })
-        }
-      })
-      // Keep list ordered by folders, then name
-      return this.$scope.docs.sort(function(a, b) {
-        const aDepth = (a.path.match(/\//g) || []).length
-        const bDepth = (b.path.match(/\//g) || []).length
-        if (aDepth - bDepth !== 0) {
-          return -(aDepth - bDepth) // Deeper path == folder first
-        } else if (a.path < b.path) {
-          return -1
-        } else {
-          return 1
-        }
+  loadRootFolder() {
+    return (this.$scope.rootFolder = this._parseFolder(
+      __guard__(
+        this.$scope != null ? this.$scope.project : undefined,
+        x => x.rootFolder[0]
+      )
+    ))
+  }
+
+  _parseFolder(rawFolder) {
+    const folder = {
+      name: rawFolder.name,
+      id: rawFolder._id,
+      type: 'folder',
+      children: [],
+      selected: rawFolder._id === this.selected_entity_id
+    }
+
+    for (let doc of Array.from(rawFolder.docs || [])) {
+      folder.children.push({
+        name: doc.name,
+        id: doc._id,
+        type: 'doc',
+        selected: doc._id === this.selected_entity_id
       })
     }
 
-    getEntityPath(entity) {
-      return this._getEntityPathInFolder(this.$scope.rootFolder, entity)
+    for (let file of Array.from(rawFolder.fileRefs || [])) {
+      folder.children.push({
+        name: file.name,
+        id: file._id,
+        type: 'file',
+        selected: file._id === this.selected_entity_id,
+        linkedFileData: file.linkedFileData,
+        created: file.created
+      })
     }
 
-    _getEntityPathInFolder(folder, entity) {
-      for (let child of Array.from(folder.children || [])) {
-        if (child === entity) {
-          return entity.name
-        } else if (child.type === 'folder') {
-          const path = this._getEntityPathInFolder(child, entity)
-          if (path != null) {
-            return child.name + '/' + path
-          }
-        }
-      }
-      return null
+    for (let childFolder of Array.from(rawFolder.folders || [])) {
+      folder.children.push(this._parseFolder(childFolder))
     }
 
-    getCurrentFolder() {
-      // Return the root folder if nothing is selected
-      return (
-        this._getCurrentFolder(this.$scope.rootFolder) || this.$scope.rootFolder
-      )
-    }
+    return folder
+  }
 
-    _getCurrentFolder(startFolder) {
-      if (startFolder == null) {
-        startFolder = this.$scope.rootFolder
+  loadDeletedDocs() {
+    this.$scope.deletedDocs = []
+    return Array.from(this.$scope.project.deletedDocs || []).map(doc =>
+      this.$scope.deletedDocs.push({
+        name: doc.name,
+        id: doc._id,
+        type: 'doc',
+        deleted: true
+      })
+    )
+  }
+
+  recalculateDocList() {
+    this.$scope.docs = []
+    this.forEachEntity((entity, parentFolder, path) => {
+      if (entity.type === 'doc' && !entity.deleted) {
+        return this.$scope.docs.push({
+          doc: entity,
+          path
+        })
       }
-      for (let entity of Array.from(startFolder.children || [])) {
-        // The 'current' folder is either the one selected, or
-        // the one containing the selected doc/file
-        if (entity.selected) {
-          if (entity.type === 'folder') {
-            return entity
-          } else {
-            return startFolder
-          }
-        }
-
-        if (entity.type === 'folder') {
-          const result = this._getCurrentFolder(entity)
-          if (result != null) {
-            return result
-          }
+    })
+    // Keep list ordered by folders, then name
+    return this.$scope.docs.sort(function(a, b) {
+      const aDepth = (a.path.match(/\//g) || []).length
+      const bDepth = (b.path.match(/\//g) || []).length
+      if (aDepth - bDepth !== 0) {
+        return -(aDepth - bDepth) // Deeper path == folder first
+      } else if (a.path < b.path) {
+        return -1
+      } else {
+        return 1
+      }
+    })
+  }
+
+  getEntityPath(entity) {
+    return this._getEntityPathInFolder(this.$scope.rootFolder, entity)
+  }
+
+  _getEntityPathInFolder(folder, entity) {
+    for (let child of Array.from(folder.children || [])) {
+      if (child === entity) {
+        return entity.name
+      } else if (child.type === 'folder') {
+        const path = this._getEntityPathInFolder(child, entity)
+        if (path != null) {
+          return child.name + '/' + path
         }
       }
-
-      return null
     }
+    return null
+  }
+
+  getCurrentFolder() {
+    // Return the root folder if nothing is selected
+    return (
+      this._getCurrentFolder(this.$scope.rootFolder) || this.$scope.rootFolder
+    )
+  }
 
-    projectContainsFolder() {
-      for (let entity of Array.from(this.$scope.rootFolder.children)) {
+  _getCurrentFolder(startFolder) {
+    if (startFolder == null) {
+      startFolder = this.$scope.rootFolder
+    }
+    for (let entity of Array.from(startFolder.children || [])) {
+      // The 'current' folder is either the one selected, or
+      // the one containing the selected doc/file
+      if (entity.selected) {
         if (entity.type === 'folder') {
-          return true
+          return entity
+        } else {
+          return startFolder
         }
       }
-      return false
-    }
 
-    existsInThisFolder(folder, name) {
-      for (let entity of Array.from(
-        (folder != null ? folder.children : undefined) || []
-      )) {
-        if (entity.name === name) {
-          return true
+      if (entity.type === 'folder') {
+        const result = this._getCurrentFolder(entity)
+        if (result != null) {
+          return result
         }
       }
-      return false
     }
 
-    nameExistsError(message) {
-      if (message == null) {
-        message = 'already exists'
-      }
-      const nameExists = this.ide.$q.defer()
-      nameExists.reject({ data: message })
-      return nameExists.promise
-    }
+    return null
+  }
 
-    createDoc(name, parent_folder) {
-      // check if a doc/file/folder already exists with this name
-      if (parent_folder == null) {
-        parent_folder = this.getCurrentFolder()
-      }
-      if (this.existsInThisFolder(parent_folder, name)) {
-        return this.nameExistsError()
+  projectContainsFolder() {
+    for (let entity of Array.from(this.$scope.rootFolder.children)) {
+      if (entity.type === 'folder') {
+        return true
       }
-      // We'll wait for the socket.io notification to actually
-      // add the doc for us.
-      return this.ide.$http.post(`/project/${this.ide.project_id}/doc`, {
-        name,
-        parent_folder_id: parent_folder != null ? parent_folder.id : undefined,
-        _csrf: window.csrfToken
-      })
     }
+    return false
+  }
 
-    createFolder(name, parent_folder) {
-      // check if a doc/file/folder already exists with this name
-      if (parent_folder == null) {
-        parent_folder = this.getCurrentFolder()
-      }
-      if (this.existsInThisFolder(parent_folder, name)) {
-        return this.nameExistsError()
-      }
-      // We'll wait for the socket.io notification to actually
-      // add the folder for us.
-      return this.ide.$http.post(`/project/${this.ide.project_id}/folder`, {
+  existsInThisFolder(folder, name) {
+    for (let entity of Array.from(
+      (folder != null ? folder.children : undefined) || []
+    )) {
+      if (entity.name === name) {
+        return true
+      }
+    }
+    return false
+  }
+
+  nameExistsError(message) {
+    if (message == null) {
+      message = 'already exists'
+    }
+    const nameExists = this.ide.$q.defer()
+    nameExists.reject({ data: message })
+    return nameExists.promise
+  }
+
+  createDoc(name, parent_folder) {
+    // check if a doc/file/folder already exists with this name
+    if (parent_folder == null) {
+      parent_folder = this.getCurrentFolder()
+    }
+    if (this.existsInThisFolder(parent_folder, name)) {
+      return this.nameExistsError()
+    }
+    // We'll wait for the socket.io notification to actually
+    // add the doc for us.
+    return this.ide.$http.post(`/project/${this.ide.project_id}/doc`, {
+      name,
+      parent_folder_id: parent_folder != null ? parent_folder.id : undefined,
+      _csrf: window.csrfToken
+    })
+  }
+
+  createFolder(name, parent_folder) {
+    // check if a doc/file/folder already exists with this name
+    if (parent_folder == null) {
+      parent_folder = this.getCurrentFolder()
+    }
+    if (this.existsInThisFolder(parent_folder, name)) {
+      return this.nameExistsError()
+    }
+    // We'll wait for the socket.io notification to actually
+    // add the folder for us.
+    return this.ide.$http.post(`/project/${this.ide.project_id}/folder`, {
+      name,
+      parent_folder_id: parent_folder != null ? parent_folder.id : undefined,
+      _csrf: window.csrfToken
+    })
+  }
+
+  createLinkedFile(name, parent_folder, provider, data) {
+    // check if a doc/file/folder already exists with this name
+    if (parent_folder == null) {
+      parent_folder = this.getCurrentFolder()
+    }
+    if (this.existsInThisFolder(parent_folder, name)) {
+      return this.nameExistsError()
+    }
+    // We'll wait for the socket.io notification to actually
+    // add the file for us.
+    return this.ide.$http.post(
+      `/project/${this.ide.project_id}/linked_file`,
+      {
         name,
         parent_folder_id: parent_folder != null ? parent_folder.id : undefined,
+        provider,
+        data,
         _csrf: window.csrfToken
-      })
-    }
-
-    createLinkedFile(name, parent_folder, provider, data) {
-      // check if a doc/file/folder already exists with this name
-      if (parent_folder == null) {
-        parent_folder = this.getCurrentFolder()
-      }
-      if (this.existsInThisFolder(parent_folder, name)) {
-        return this.nameExistsError()
+      },
+      {
+        disableAutoLoginRedirect: true
+      }
+    )
+  }
+
+  refreshLinkedFile(file) {
+    const parent_folder = this._findParentFolder(file)
+    const provider =
+      file.linkedFileData != null ? file.linkedFileData.provider : undefined
+    if (provider == null) {
+      console.warn(`>> no provider for ${file.name}`, file)
+      return
+    }
+    return this.ide.$http.post(
+      `/project/${this.ide.project_id}/linked_file/${file.id}/refresh`,
+      {
+        _csrf: window.csrfToken
+      },
+      {
+        disableAutoLoginRedirect: true
       }
-      // We'll wait for the socket.io notification to actually
-      // add the file for us.
-      return this.ide.$http.post(
-        `/project/${this.ide.project_id}/linked_file`,
+    )
+  }
+
+  renameEntity(entity, name, callback) {
+    if (callback == null) {
+      callback = function(error) {}
+    }
+    if (entity.name === name) {
+      return
+    }
+    if (name.length >= 150) {
+      return
+    }
+    // check if a doc/file/folder already exists with this name
+    const parent_folder = this.getCurrentFolder()
+    if (this.existsInThisFolder(parent_folder, name)) {
+      return this.nameExistsError()
+    }
+    entity.renamingToName = name
+    return this.ide.$http
+      .post(
+        `/project/${this.ide.project_id}/${entity.type}/${entity.id}/rename`,
         {
           name,
-          parent_folder_id:
-            parent_folder != null ? parent_folder.id : undefined,
-          provider,
-          data,
           _csrf: window.csrfToken
-        },
-        {
-          disableAutoLoginRedirect: true
         }
       )
-    }
-
-    refreshLinkedFile(file) {
-      const parent_folder = this._findParentFolder(file)
-      const provider =
-        file.linkedFileData != null ? file.linkedFileData.provider : undefined
-      if (provider == null) {
-        console.warn(`>> no provider for ${file.name}`, file)
-        return
-      }
-      return this.ide.$http.post(
-        `/project/${this.ide.project_id}/linked_file/${file.id}/refresh`,
+      .then(() => (entity.name = name))
+      .finally(() => (entity.renamingToName = null))
+  }
+
+  deleteEntity(entity, callback) {
+    // We'll wait for the socket.io notification to
+    // delete from scope.
+    if (callback == null) {
+      callback = function(error) {}
+    }
+    return this.ide.queuedHttp({
+      method: 'DELETE',
+      url: `/project/${this.ide.project_id}/${entity.type}/${entity.id}`,
+      headers: {
+        'X-Csrf-Token': window.csrfToken
+      }
+    })
+  }
+
+  moveEntity(entity, parent_folder) {
+    // Abort move if the folder being moved (entity) has the parent_folder as child
+    // since that would break the tree structure.
+    if (this._isChildFolder(entity, parent_folder)) {
+      return
+    }
+    // check if a doc/file/folder already exists with this name
+    if (this.existsInThisFolder(parent_folder, entity.name)) {
+      throw new Error('file exists in this location')
+    }
+    // Wait for the http response before doing the move
+    this.ide.queuedHttp
+      .post(
+        `/project/${this.ide.project_id}/${entity.type}/${entity.id}/move`,
         {
+          folder_id: parent_folder.id,
           _csrf: window.csrfToken
-        },
-        {
-          disableAutoLoginRedirect: true
         }
       )
-    }
-
-    renameEntity(entity, name, callback) {
-      if (callback == null) {
-        callback = function(error) {}
-      }
-      if (entity.name === name) {
-        return
-      }
-      if (name.length >= 150) {
-        return
-      }
-      // check if a doc/file/folder already exists with this name
-      const parent_folder = this.getCurrentFolder()
-      if (this.existsInThisFolder(parent_folder, name)) {
-        return this.nameExistsError()
-      }
-      entity.renamingToName = name
-      return this.ide.$http
-        .post(
-          `/project/${this.ide.project_id}/${entity.type}/${entity.id}/rename`,
-          {
-            name,
-            _csrf: window.csrfToken
-          }
-        )
-        .then(() => (entity.name = name))
-        .finally(() => (entity.renamingToName = null))
-    }
-
-    deleteEntity(entity, callback) {
-      // We'll wait for the socket.io notification to
-      // delete from scope.
-      if (callback == null) {
-        callback = function(error) {}
-      }
-      return this.ide.queuedHttp({
-        method: 'DELETE',
-        url: `/project/${this.ide.project_id}/${entity.type}/${entity.id}`,
-        headers: {
-          'X-Csrf-Token': window.csrfToken
-        }
+      .then(() => {
+        this._moveEntityInScope(entity, parent_folder)
       })
-    }
+  }
 
-    moveEntity(entity, parent_folder) {
-      // Abort move if the folder being moved (entity) has the parent_folder as child
-      // since that would break the tree structure.
-      if (this._isChildFolder(entity, parent_folder)) {
-        return
-      }
-      // check if a doc/file/folder already exists with this name
-      if (this.existsInThisFolder(parent_folder, entity.name)) {
-        throw new Error('file exists in this location')
-      }
-      // Wait for the http response before doing the move
-      this.ide.queuedHttp
-        .post(
-          `/project/${this.ide.project_id}/${entity.type}/${entity.id}/move`,
-          {
-            folder_id: parent_folder.id,
-            _csrf: window.csrfToken
-          }
-        )
-        .then(() => {
-          this._moveEntityInScope(entity, parent_folder)
-        })
-    }
+  _isChildFolder(parent_folder, child_folder) {
+    const parent_path = this.getEntityPath(parent_folder) || '' // null if root folder
+    const child_path = this.getEntityPath(child_folder) || '' // null if root folder
+    // is parent path the beginning of child path?
+    return child_path.slice(0, parent_path.length) === parent_path
+  }
 
-    _isChildFolder(parent_folder, child_folder) {
-      const parent_path = this.getEntityPath(parent_folder) || '' // null if root folder
-      const child_path = this.getEntityPath(child_folder) || '' // null if root folder
-      // is parent path the beginning of child path?
-      return child_path.slice(0, parent_path.length) === parent_path
+  _deleteEntityFromScope(entity, options) {
+    if (options == null) {
+      options = { moveToDeleted: true }
     }
-
-    _deleteEntityFromScope(entity, options) {
-      if (options == null) {
-        options = { moveToDeleted: true }
-      }
-      if (entity == null) {
-        return
+    if (entity == null) {
+      return
+    }
+    let parent_folder = null
+    this.forEachEntity(function(possible_entity, folder) {
+      if (possible_entity === entity) {
+        return (parent_folder = folder)
       }
-      let parent_folder = null
-      this.forEachEntity(function(possible_entity, folder) {
-        if (possible_entity === entity) {
-          return (parent_folder = folder)
-        }
-      })
+    })
 
-      if (parent_folder != null) {
-        const index = parent_folder.children.indexOf(entity)
-        if (index > -1) {
-          parent_folder.children.splice(index, 1)
-        }
+    if (parent_folder != null) {
+      const index = parent_folder.children.indexOf(entity)
+      if (index > -1) {
+        parent_folder.children.splice(index, 1)
       }
+    }
 
-      if (entity.type !== 'folder' && entity.selected) {
-        this.$scope.ui.view = null
-      }
+    if (entity.type !== 'folder' && entity.selected) {
+      this.$scope.ui.view = null
+    }
 
-      if (entity.type === 'doc' && options.moveToDeleted) {
-        entity.deleted = true
-        return this.$scope.deletedDocs.push(entity)
-      }
+    if (entity.type === 'doc' && options.moveToDeleted) {
+      entity.deleted = true
+      return this.$scope.deletedDocs.push(entity)
     }
+  }
 
-    _moveEntityInScope(entity, parent_folder) {
-      if (Array.from(parent_folder.children).includes(entity)) {
-        return
-      }
-      this._deleteEntityFromScope(entity, { moveToDeleted: false })
-      return parent_folder.children.push(entity)
+  _moveEntityInScope(entity, parent_folder) {
+    if (Array.from(parent_folder.children).includes(entity)) {
+      return
     }
-  })
+    this._deleteEntityFromScope(entity, { moveToDeleted: false })
+    return parent_folder.children.push(entity)
+  }
 })
 
 function __guard__(value, transform) {

+ 547 - 553
services/web/frontend/js/ide/file-tree/controllers/FileTreeController.js

@@ -14,619 +14,613 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], function(App) {
-  App.controller('FileTreeController', function(
-    $scope,
-    $modal,
-    ide,
-    $rootScope
-  ) {
-    $scope.openNewDocModal = () =>
-      $modal.open({
-        templateUrl: 'newFileModalTemplate',
-        controller: 'NewFileModalController',
-        size: 'lg',
-        resolve: {
-          parent_folder() {
-            return ide.fileTreeManager.getCurrentFolder()
-          },
-          projectFeatures() {
-            return ide.$scope.project.features
-          },
-          type() {
-            return 'doc'
-          },
-          userFeatures() {
-            return ide.$scope.user.features
-          }
+import App from '../../../base'
+App.controller('FileTreeController', function($scope, $modal, ide, $rootScope) {
+  $scope.openNewDocModal = () =>
+    $modal.open({
+      templateUrl: 'newFileModalTemplate',
+      controller: 'NewFileModalController',
+      size: 'lg',
+      resolve: {
+        parent_folder() {
+          return ide.fileTreeManager.getCurrentFolder()
+        },
+        projectFeatures() {
+          return ide.$scope.project.features
+        },
+        type() {
+          return 'doc'
+        },
+        userFeatures() {
+          return ide.$scope.user.features
         }
-      })
+      }
+    })
 
-    $scope.openNewFolderModal = () =>
-      $modal.open({
-        templateUrl: 'newFolderModalTemplate',
-        controller: 'NewFolderModalController',
-        resolve: {
-          parent_folder() {
-            return ide.fileTreeManager.getCurrentFolder()
-          }
+  $scope.openNewFolderModal = () =>
+    $modal.open({
+      templateUrl: 'newFolderModalTemplate',
+      controller: 'NewFolderModalController',
+      resolve: {
+        parent_folder() {
+          return ide.fileTreeManager.getCurrentFolder()
         }
-      })
+      }
+    })
 
-    $scope.openUploadFileModal = () =>
-      $modal.open({
-        templateUrl: 'newFileModalTemplate',
-        controller: 'NewFileModalController',
-        size: 'lg',
-        resolve: {
-          projectFeatures() {
-            return ide.$scope.project.features
-          },
-          parent_folder() {
-            return ide.fileTreeManager.getCurrentFolder()
-          },
-          type() {
-            return 'upload'
-          },
-          userFeatures() {
-            return ide.$scope.user.features
-          }
+  $scope.openUploadFileModal = () =>
+    $modal.open({
+      templateUrl: 'newFileModalTemplate',
+      controller: 'NewFileModalController',
+      size: 'lg',
+      resolve: {
+        projectFeatures() {
+          return ide.$scope.project.features
+        },
+        parent_folder() {
+          return ide.fileTreeManager.getCurrentFolder()
+        },
+        type() {
+          return 'upload'
+        },
+        userFeatures() {
+          return ide.$scope.user.features
         }
-      })
-
-    $scope.orderByFoldersFirst = function(entity) {
-      if ((entity != null ? entity.type : undefined) === 'folder') {
-        return '0'
       }
-      return '1'
+    })
+
+  $scope.orderByFoldersFirst = function(entity) {
+    if ((entity != null ? entity.type : undefined) === 'folder') {
+      return '0'
     }
+    return '1'
+  }
 
-    $scope.startRenamingSelected = () => $scope.$broadcast('rename:selected')
+  $scope.startRenamingSelected = () => $scope.$broadcast('rename:selected')
 
-    return ($scope.openDeleteModalForSelected = () =>
-      $scope.$broadcast('delete:selected'))
-  })
+  return ($scope.openDeleteModalForSelected = () =>
+    $scope.$broadcast('delete:selected'))
+})
 
-  App.controller('NewFolderModalController', function(
-    $scope,
-    ide,
-    $modalInstance,
-    $timeout,
-    parent_folder
-  ) {
-    $scope.inputs = { name: 'name' }
-    $scope.state = { inflight: false }
-
-    $modalInstance.opened.then(() =>
-      $timeout(() => $scope.$broadcast('open'), 200)
-    )
+App.controller('NewFolderModalController', function(
+  $scope,
+  ide,
+  $modalInstance,
+  $timeout,
+  parent_folder
+) {
+  $scope.inputs = { name: 'name' }
+  $scope.state = { inflight: false }
+
+  $modalInstance.opened.then(() =>
+    $timeout(() => $scope.$broadcast('open'), 200)
+  )
 
-    $scope.create = function() {
-      const { name } = $scope.inputs
-      if (name == null || name.length === 0) {
-        return
-      }
-      $scope.state.inflight = true
-      return ide.fileTreeManager
-        .createFolder(name, $scope.parent_folder)
-        .then(function() {
-          $scope.state.inflight = false
-          return $modalInstance.dismiss('done')
-        })
-        .catch(function(response) {
-          const { data } = response
-          $scope.error = data
-          return ($scope.state.inflight = false)
-        })
+  $scope.create = function() {
+    const { name } = $scope.inputs
+    if (name == null || name.length === 0) {
+      return
     }
+    $scope.state.inflight = true
+    return ide.fileTreeManager
+      .createFolder(name, $scope.parent_folder)
+      .then(function() {
+        $scope.state.inflight = false
+        return $modalInstance.dismiss('done')
+      })
+      .catch(function(response) {
+        const { data } = response
+        $scope.error = data
+        return ($scope.state.inflight = false)
+      })
+  }
 
-    return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
-  })
+  return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
+})
 
-  App.controller('DuplicateFileModalController', function(
-    $scope,
-    $modalInstance,
-    fileName
-  ) {
-    $scope.fileName = fileName
-    $scope.cancel = () => $modalInstance.dismiss('cancel')
-  })
+App.controller('DuplicateFileModalController', function(
+  $scope,
+  $modalInstance,
+  fileName
+) {
+  $scope.fileName = fileName
+  $scope.cancel = () => $modalInstance.dismiss('cancel')
+})
 
-  App.controller('NewFileModalController', function(
-    $scope,
-    ide,
-    type,
-    parent_folder,
-    $modalInstance,
-    eventTracking,
-    projectFeatures,
-    userFeatures
-  ) {
-    $scope.file_count = ide.fileTreeManager.getFullCount()
-    $scope.type = type
-    $scope.parent_folder = parent_folder
-    $scope.state = {
-      inflight: false,
-      valid: true
+App.controller('NewFileModalController', function(
+  $scope,
+  ide,
+  type,
+  parent_folder,
+  $modalInstance,
+  eventTracking,
+  projectFeatures,
+  userFeatures
+) {
+  $scope.file_count = ide.fileTreeManager.getFullCount()
+  $scope.type = type
+  $scope.parent_folder = parent_folder
+  $scope.state = {
+    inflight: false,
+    valid: true
+  }
+  $scope.cancel = () => $modalInstance.dismiss('cancel')
+  $scope.create = () => $scope.$broadcast('create')
+
+  const hasMendeleyFeature =
+    (projectFeatures && projectFeatures.references) ||
+    (projectFeatures && projectFeatures.mendeley) ||
+    (userFeatures && userFeatures.references) ||
+    (userFeatures && userFeatures.mendeley)
+
+  const hasZoteroFeature =
+    (projectFeatures && projectFeatures.references) ||
+    (projectFeatures && projectFeatures.zotero) ||
+    (userFeatures && userFeatures.references) ||
+    (userFeatures && userFeatures.zotero)
+
+  $scope.$watch('type', function() {
+    if ($scope.type === 'mendeley' && !hasMendeleyFeature) {
+      eventTracking.send(
+        'subscription-funnel',
+        'editor-click-feature',
+        $scope.type
+      )
+    }
+    if ($scope.type === 'zotero' && !hasZoteroFeature) {
+      eventTracking.send(
+        'subscription-funnel',
+        'editor-click-feature',
+        $scope.type
+      )
     }
-    $scope.cancel = () => $modalInstance.dismiss('cancel')
-    $scope.create = () => $scope.$broadcast('create')
-
-    const hasMendeleyFeature =
-      (projectFeatures && projectFeatures.references) ||
-      (projectFeatures && projectFeatures.mendeley) ||
-      (userFeatures && userFeatures.references) ||
-      (userFeatures && userFeatures.mendeley)
-
-    const hasZoteroFeature =
-      (projectFeatures && projectFeatures.references) ||
-      (projectFeatures && projectFeatures.zotero) ||
-      (userFeatures && userFeatures.references) ||
-      (userFeatures && userFeatures.zotero)
-
-    $scope.$watch('type', function() {
-      if ($scope.type === 'mendeley' && !hasMendeleyFeature) {
-        eventTracking.send(
-          'subscription-funnel',
-          'editor-click-feature',
-          $scope.type
-        )
-      }
-      if ($scope.type === 'zotero' && !hasZoteroFeature) {
-        eventTracking.send(
-          'subscription-funnel',
-          'editor-click-feature',
-          $scope.type
-        )
-      }
-    })
-    return $scope.$on('done', (e, opts = {}) => {
-      const isBibFile = opts.name && /^.*\.bib$/.test(opts.name)
-      if (opts.shouldReindexReferences || isBibFile) {
-        ide.$scope.$emit('references:should-reindex', {})
-      }
-      $modalInstance.dismiss('done')
-    })
   })
+  return $scope.$on('done', (e, opts = {}) => {
+    const isBibFile = opts.name && /^.*\.bib$/.test(opts.name)
+    if (opts.shouldReindexReferences || isBibFile) {
+      ide.$scope.$emit('references:should-reindex', {})
+    }
+    $modalInstance.dismiss('done')
+  })
+})
 
-  App.controller('NewDocModalController', function($scope, ide, $timeout) {
-    $scope.inputs = { name: 'name.tex' }
-
-    $timeout(() => $scope.$broadcast('open'), 200)
+App.controller('NewDocModalController', function($scope, ide, $timeout) {
+  $scope.inputs = { name: 'name.tex' }
 
-    return $scope.$on('create', function() {
-      const { name } = $scope.inputs
-      if (name == null || name.length === 0) {
-        return
-      }
-      $scope.state.inflight = true
-      return ide.fileTreeManager
-        .createDoc(name, $scope.parent_folder)
-        .then(function() {
-          $scope.state.inflight = false
-          return $scope.$emit('done')
-        })
-        .catch(function(response) {
-          const { data } = response
-          $scope.error = data
-          $scope.state.inflight = false
-        })
-    })
-  })
+  $timeout(() => $scope.$broadcast('open'), 200)
 
-  App.controller('UploadFileModalController', function(
-    $scope,
-    $rootScope,
-    ide,
-    $timeout,
-    $window
-  ) {
-    $scope.parent_folder_id =
-      $scope.parent_folder != null ? $scope.parent_folder.id : undefined
-    $scope.project_id = ide.project_id
-    $scope.tooManyFiles = false
-    $scope.rateLimitHit = false
-    $scope.secondsToRedirect = 10
-    $scope.notLoggedIn = false
-    $scope.conflicts = []
-    $scope.control = {}
-
-    const needToLogBackIn = function() {
-      $scope.notLoggedIn = true
-      var decreseTimeout = () =>
-        $timeout(function() {
-          if ($scope.secondsToRedirect === 0) {
-            return ($window.location.href = `/login?redir=/project/${
-              ide.project_id
-            }`)
-          } else {
-            decreseTimeout()
-            return ($scope.secondsToRedirect = $scope.secondsToRedirect - 1)
-          }
-        }, 1000)
-
-      return decreseTimeout()
+  return $scope.$on('create', function() {
+    const { name } = $scope.inputs
+    if (name == null || name.length === 0) {
+      return
     }
+    $scope.state.inflight = true
+    return ide.fileTreeManager
+      .createDoc(name, $scope.parent_folder)
+      .then(function() {
+        $scope.state.inflight = false
+        return $scope.$emit('done')
+      })
+      .catch(function(response) {
+        const { data } = response
+        $scope.error = data
+        $scope.state.inflight = false
+      })
+  })
+})
 
-    $scope.max_files = 40
-    $scope.onComplete = (error, name, response) =>
+App.controller('UploadFileModalController', function(
+  $scope,
+  $rootScope,
+  ide,
+  $timeout,
+  $window
+) {
+  $scope.parent_folder_id =
+    $scope.parent_folder != null ? $scope.parent_folder.id : undefined
+  $scope.project_id = ide.project_id
+  $scope.tooManyFiles = false
+  $scope.rateLimitHit = false
+  $scope.secondsToRedirect = 10
+  $scope.notLoggedIn = false
+  $scope.conflicts = []
+  $scope.control = {}
+
+  const needToLogBackIn = function() {
+    $scope.notLoggedIn = true
+    var decreseTimeout = () =>
       $timeout(function() {
-        uploadCount--
-        if (response.success) {
-          $rootScope.$broadcast('file:upload:complete', response)
+        if ($scope.secondsToRedirect === 0) {
+          return ($window.location.href = `/login?redir=/project/${
+            ide.project_id
+          }`)
+        } else {
+          decreseTimeout()
+          return ($scope.secondsToRedirect = $scope.secondsToRedirect - 1)
         }
-        if (uploadCount === 0 && response != null && response.success) {
-          return $scope.$emit('done', { name: name })
-        }
-      }, 250)
-
-    $scope.onValidateBatch = function(files) {
-      if (files.length > $scope.max_files) {
-        $timeout(() => ($scope.tooManyFiles = true), 1)
-        return false
-      } else {
-        return true
-      }
-    }
+      }, 1000)
 
-    $scope.onError = function(id, name, reason) {
-      console.log(id, name, reason)
-      if (reason.indexOf('429') !== -1) {
-        return ($scope.rateLimitHit = true)
-      } else if (reason.indexOf('403') !== -1) {
-        return needToLogBackIn()
-      }
-    }
+    return decreseTimeout()
+  }
 
-    let _uploadTimer = null
-    const uploadIfNoConflicts = function() {
-      if ($scope.conflicts.length === 0) {
-        return $scope.doUpload()
-      }
-    }
-
-    var uploadCount = 0
-    $scope.onSubmit = function(id, name) {
-      uploadCount++
-      if (ide.fileTreeManager.existsInFolder($scope.parent_folder_id, name)) {
-        $scope.conflicts.push(name)
-        $scope.$apply()
+  $scope.max_files = 40
+  $scope.onComplete = (error, name, response) =>
+    $timeout(function() {
+      uploadCount--
+      if (response.success) {
+        $rootScope.$broadcast('file:upload:complete', response)
       }
-      if (_uploadTimer == null) {
-        _uploadTimer = setTimeout(function() {
-          _uploadTimer = null
-          return uploadIfNoConflicts()
-        }, 0)
+      if (uploadCount === 0 && response != null && response.success) {
+        return $scope.$emit('done', { name: name })
       }
+    }, 250)
+
+  $scope.onValidateBatch = function(files) {
+    if (files.length > $scope.max_files) {
+      $timeout(() => ($scope.tooManyFiles = true), 1)
+      return false
+    } else {
       return true
     }
-
-    $scope.onCancel = function(id, name) {
-      uploadCount--
-      const index = $scope.conflicts.indexOf(name)
-      if (index > -1) {
-        $scope.conflicts.splice(index, 1)
-      }
-      $scope.$apply()
-      return uploadIfNoConflicts()
+  }
+
+  $scope.onError = function(id, name, reason) {
+    console.log(id, name, reason)
+    if (reason.indexOf('429') !== -1) {
+      return ($scope.rateLimitHit = true)
+    } else if (reason.indexOf('403') !== -1) {
+      return needToLogBackIn()
     }
+  }
 
-    return ($scope.doUpload = () =>
-      __guard__($scope.control != null ? $scope.control.q : undefined, x =>
-        x.uploadStoredFiles()
-      ))
-  })
-
-  App.controller('ProjectLinkedFileModalController', function(
-    $scope,
-    ide,
-    $timeout
-  ) {
-    $scope.data = {
-      projects: null, // or []
-      selectedProjectId: null,
-      projectEntities: null, // or []
-      projectOutputFiles: null, // or []
-      selectedProjectEntity: null,
-      selectedProjectOutputFile: null,
-      buildId: null,
-      name: null
+  let _uploadTimer = null
+  const uploadIfNoConflicts = function() {
+    if ($scope.conflicts.length === 0) {
+      return $scope.doUpload()
     }
-    $scope.state.inFlight = {
-      projects: false,
-      entities: false,
-      compile: false
-    }
-    $scope.state.isOutputFilesMode = false
-    $scope.state.error = false
-
-    $scope.$watch('data.selectedProjectId', function(newVal, oldVal) {
-      if (!newVal) {
-        return
-      }
-      $scope.data.selectedProjectEntity = null
-      $scope.data.selectedProjectOutputFile = null
-      if ($scope.state.isOutputFilesMode) {
-        return $scope.compileProjectAndGetOutputFiles(
-          $scope.data.selectedProjectId
-        )
-      } else {
-        return $scope.getProjectEntities($scope.data.selectedProjectId)
-      }
-    })
-
-    $scope.$watch('state.isOutputFilesMode', function(newVal, oldVal) {
-      if (!newVal && !oldVal) {
-        return
-      }
-      $scope.data.selectedProjectOutputFile = null
-      if (newVal === true) {
-        return $scope.compileProjectAndGetOutputFiles(
-          $scope.data.selectedProjectId
-        )
-      } else {
-        return $scope.getProjectEntities($scope.data.selectedProjectId)
-      }
-    })
-
-    // auto-set filename based on selected file
-    $scope.$watch('data.selectedProjectEntity', function(newVal, oldVal) {
-      if (!newVal) {
-        return
-      }
-      const fileName = newVal.split('/').reverse()[0]
-      if (fileName) {
-        $scope.data.name = fileName
-      }
-    })
-
-    // auto-set filename based on selected file
-    $scope.$watch('data.selectedProjectOutputFile', function(newVal, oldVal) {
-      if (!newVal) {
-        return
-      }
-      if (newVal === 'output.pdf') {
-        const project = _.find(
-          $scope.data.projects,
-          p => p._id === $scope.data.selectedProjectId
-        )
-        $scope.data.name =
-          (project != null ? project.name : undefined) != null
-            ? `${project.name}.pdf`
-            : 'output.pdf'
-      } else {
-        const fileName = newVal.split('/').reverse()[0]
-        if (fileName) {
-          $scope.data.name = fileName
-        }
-      }
-    })
-
-    const _setInFlight = type => ($scope.state.inFlight[type] = true)
+  }
 
-    const _reset = function(opts) {
-      const isError = opts.err === true
-      const { inFlight } = $scope.state
-      inFlight.projects = inFlight.entities = inFlight.compile = false
-      $scope.state.inflight = false
-      return ($scope.state.error = isError)
+  var uploadCount = 0
+  $scope.onSubmit = function(id, name) {
+    uploadCount++
+    if (ide.fileTreeManager.existsInFolder($scope.parent_folder_id, name)) {
+      $scope.conflicts.push(name)
+      $scope.$apply()
     }
-
-    $scope.toggleOutputFilesMode = function() {
-      if (!$scope.data.selectedProjectId) {
-        return
-      }
-      return ($scope.state.isOutputFilesMode = !$scope.state.isOutputFilesMode)
+    if (_uploadTimer == null) {
+      _uploadTimer = setTimeout(function() {
+        _uploadTimer = null
+        return uploadIfNoConflicts()
+      }, 0)
     }
-
-    $scope.shouldEnableProjectSelect = function() {
-      const { state, data } = $scope
-      return !state.inFlight.projects && data.projects
+    return true
+  }
+
+  $scope.onCancel = function(id, name) {
+    uploadCount--
+    const index = $scope.conflicts.indexOf(name)
+    if (index > -1) {
+      $scope.conflicts.splice(index, 1)
     }
+    $scope.$apply()
+    return uploadIfNoConflicts()
+  }
+
+  return ($scope.doUpload = () =>
+    __guard__($scope.control != null ? $scope.control.q : undefined, x =>
+      x.uploadStoredFiles()
+    ))
+})
 
-    $scope.hasNoProjects = function() {
-      const { state, data } = $scope
-      return (
-        !state.inFlight.projects &&
-        (data.projects == null || data.projects.length === 0)
-      )
+App.controller('ProjectLinkedFileModalController', function(
+  $scope,
+  ide,
+  $timeout
+) {
+  $scope.data = {
+    projects: null, // or []
+    selectedProjectId: null,
+    projectEntities: null, // or []
+    projectOutputFiles: null, // or []
+    selectedProjectEntity: null,
+    selectedProjectOutputFile: null,
+    buildId: null,
+    name: null
+  }
+  $scope.state.inFlight = {
+    projects: false,
+    entities: false,
+    compile: false
+  }
+  $scope.state.isOutputFilesMode = false
+  $scope.state.error = false
+
+  $scope.$watch('data.selectedProjectId', function(newVal, oldVal) {
+    if (!newVal) {
+      return
     }
-
-    $scope.shouldEnableProjectEntitySelect = function() {
-      const { state, data } = $scope
-      return (
-        !state.inFlight.projects &&
-        !state.inFlight.entities &&
-        data.projects &&
-        data.selectedProjectId
+    $scope.data.selectedProjectEntity = null
+    $scope.data.selectedProjectOutputFile = null
+    if ($scope.state.isOutputFilesMode) {
+      return $scope.compileProjectAndGetOutputFiles(
+        $scope.data.selectedProjectId
       )
+    } else {
+      return $scope.getProjectEntities($scope.data.selectedProjectId)
     }
+  })
 
-    $scope.shouldEnableProjectOutputFileSelect = function() {
-      const { state, data } = $scope
-      return (
-        !state.inFlight.projects &&
-        !state.inFlight.compile &&
-        data.projects &&
-        data.selectedProjectId
+  $scope.$watch('state.isOutputFilesMode', function(newVal, oldVal) {
+    if (!newVal && !oldVal) {
+      return
+    }
+    $scope.data.selectedProjectOutputFile = null
+    if (newVal === true) {
+      return $scope.compileProjectAndGetOutputFiles(
+        $scope.data.selectedProjectId
       )
+    } else {
+      return $scope.getProjectEntities($scope.data.selectedProjectId)
     }
+  })
 
-    const validate = function() {
-      const { state } = $scope
-      const { data } = $scope
-      $scope.state.valid =
-        !state.inFlight.projects &&
-        !state.inFlight.entities &&
-        data.projects &&
-        data.selectedProjectId &&
-        ((!$scope.state.isOutputFilesMode &&
-          data.projectEntities &&
-          data.selectedProjectEntity) ||
-          ($scope.state.isOutputFilesMode &&
-            data.projectOutputFiles &&
-            data.selectedProjectOutputFile)) &&
-        data.name
+  // auto-set filename based on selected file
+  $scope.$watch('data.selectedProjectEntity', function(newVal, oldVal) {
+    if (!newVal) {
+      return
     }
-    $scope.$watch('state', validate, true)
-    $scope.$watch('data', validate, true)
-
-    $scope.getUserProjects = function() {
-      _setInFlight('projects')
-      return ide.$http
-        .get('/user/projects', {
-          _csrf: window.csrfToken
-        })
-        .then(function(resp) {
-          $scope.data.projectEntities = null
-          $scope.data.projects = resp.data.projects.filter(
-            p => p._id !== ide.project_id
-          )
-          return _reset({ err: false })
-        })
-        .catch(err => _reset({ err: true }))
+    const fileName = newVal.split('/').reverse()[0]
+    if (fileName) {
+      $scope.data.name = fileName
     }
+  })
 
-    $scope.getProjectEntities = project_id => {
-      _setInFlight('entities')
-      return ide.$http
-        .get(`/project/${project_id}/entities`, {
-          _csrf: window.csrfToken
-        })
-        .then(function(resp) {
-          if ($scope.data.selectedProjectId === resp.data.project_id) {
-            $scope.data.projectEntities = resp.data.entities
-            return _reset({ err: false })
-          }
-        })
-        .catch(err => _reset({ err: true }))
+  // auto-set filename based on selected file
+  $scope.$watch('data.selectedProjectOutputFile', function(newVal, oldVal) {
+    if (!newVal) {
+      return
     }
-
-    $scope.compileProjectAndGetOutputFiles = project_id => {
-      _setInFlight('compile')
-      return ide.$http
-        .post(`/project/${project_id}/compile`, {
-          check: 'silent',
-          draft: false,
-          incrementalCompilesEnabled: false,
-          _csrf: window.csrfToken
-        })
-        .then(function(resp) {
-          if (resp.data.status === 'success') {
-            const filteredFiles = resp.data.outputFiles.filter(f =>
-              f.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
-            )
-            $scope.data.projectOutputFiles = filteredFiles
-            $scope.data.buildId = __guard__(
-              filteredFiles != null ? filteredFiles[0] : undefined,
-              x => x.build
-            )
-            console.log('>> build_id', $scope.data.buildId)
-            return _reset({ err: false })
-          } else {
-            $scope.data.projectOutputFiles = null
-            return _reset({ err: true })
-          }
-        })
-        .catch(function(err) {
-          console.error(err)
-          return _reset({ err: true })
-        })
+    if (newVal === 'output.pdf') {
+      const project = _.find(
+        $scope.data.projects,
+        p => p._id === $scope.data.selectedProjectId
+      )
+      $scope.data.name =
+        (project != null ? project.name : undefined) != null
+          ? `${project.name}.pdf`
+          : 'output.pdf'
+    } else {
+      const fileName = newVal.split('/').reverse()[0]
+      if (fileName) {
+        $scope.data.name = fileName
+      }
     }
+  })
+
+  const _setInFlight = type => ($scope.state.inFlight[type] = true)
 
-    $scope.init = () => $scope.getUserProjects()
-    $timeout($scope.init, 0)
-
-    return $scope.$on('create', function() {
-      let payload, provider
-      const projectId = $scope.data.selectedProjectId
-      const { name } = $scope.data
-      if ($scope.state.isOutputFilesMode) {
-        provider = 'project_output_file'
-        payload = {
-          source_project_id: projectId,
-          source_output_file_path: $scope.data.selectedProjectOutputFile,
-          build_id: $scope.data.buildId
+  const _reset = function(opts) {
+    const isError = opts.err === true
+    const { inFlight } = $scope.state
+    inFlight.projects = inFlight.entities = inFlight.compile = false
+    $scope.state.inflight = false
+    return ($scope.state.error = isError)
+  }
+
+  $scope.toggleOutputFilesMode = function() {
+    if (!$scope.data.selectedProjectId) {
+      return
+    }
+    return ($scope.state.isOutputFilesMode = !$scope.state.isOutputFilesMode)
+  }
+
+  $scope.shouldEnableProjectSelect = function() {
+    const { state, data } = $scope
+    return !state.inFlight.projects && data.projects
+  }
+
+  $scope.hasNoProjects = function() {
+    const { state, data } = $scope
+    return (
+      !state.inFlight.projects &&
+      (data.projects == null || data.projects.length === 0)
+    )
+  }
+
+  $scope.shouldEnableProjectEntitySelect = function() {
+    const { state, data } = $scope
+    return (
+      !state.inFlight.projects &&
+      !state.inFlight.entities &&
+      data.projects &&
+      data.selectedProjectId
+    )
+  }
+
+  $scope.shouldEnableProjectOutputFileSelect = function() {
+    const { state, data } = $scope
+    return (
+      !state.inFlight.projects &&
+      !state.inFlight.compile &&
+      data.projects &&
+      data.selectedProjectId
+    )
+  }
+
+  const validate = function() {
+    const { state } = $scope
+    const { data } = $scope
+    $scope.state.valid =
+      !state.inFlight.projects &&
+      !state.inFlight.entities &&
+      data.projects &&
+      data.selectedProjectId &&
+      ((!$scope.state.isOutputFilesMode &&
+        data.projectEntities &&
+        data.selectedProjectEntity) ||
+        ($scope.state.isOutputFilesMode &&
+          data.projectOutputFiles &&
+          data.selectedProjectOutputFile)) &&
+      data.name
+  }
+  $scope.$watch('state', validate, true)
+  $scope.$watch('data', validate, true)
+
+  $scope.getUserProjects = function() {
+    _setInFlight('projects')
+    return ide.$http
+      .get('/user/projects', {
+        _csrf: window.csrfToken
+      })
+      .then(function(resp) {
+        $scope.data.projectEntities = null
+        $scope.data.projects = resp.data.projects.filter(
+          p => p._id !== ide.project_id
+        )
+        return _reset({ err: false })
+      })
+      .catch(err => _reset({ err: true }))
+  }
+
+  $scope.getProjectEntities = project_id => {
+    _setInFlight('entities')
+    return ide.$http
+      .get(`/project/${project_id}/entities`, {
+        _csrf: window.csrfToken
+      })
+      .then(function(resp) {
+        if ($scope.data.selectedProjectId === resp.data.project_id) {
+          $scope.data.projectEntities = resp.data.entities
+          return _reset({ err: false })
         }
-      } else {
-        provider = 'project_file'
-        payload = {
-          source_project_id: projectId,
-          source_entity_path: $scope.data.selectedProjectEntity
+      })
+      .catch(err => _reset({ err: true }))
+  }
+
+  $scope.compileProjectAndGetOutputFiles = project_id => {
+    _setInFlight('compile')
+    return ide.$http
+      .post(`/project/${project_id}/compile`, {
+        check: 'silent',
+        draft: false,
+        incrementalCompilesEnabled: false,
+        _csrf: window.csrfToken
+      })
+      .then(function(resp) {
+        if (resp.data.status === 'success') {
+          const filteredFiles = resp.data.outputFiles.filter(f =>
+            f.path.match(/.*\.(pdf|png|jpeg|jpg|gif)/)
+          )
+          $scope.data.projectOutputFiles = filteredFiles
+          $scope.data.buildId = __guard__(
+            filteredFiles != null ? filteredFiles[0] : undefined,
+            x => x.build
+          )
+          console.log('>> build_id', $scope.data.buildId)
+          return _reset({ err: false })
+        } else {
+          $scope.data.projectOutputFiles = null
+          return _reset({ err: true })
         }
+      })
+      .catch(function(err) {
+        console.error(err)
+        return _reset({ err: true })
+      })
+  }
+
+  $scope.init = () => $scope.getUserProjects()
+  $timeout($scope.init, 0)
+
+  return $scope.$on('create', function() {
+    let payload, provider
+    const projectId = $scope.data.selectedProjectId
+    const { name } = $scope.data
+    if ($scope.state.isOutputFilesMode) {
+      provider = 'project_output_file'
+      payload = {
+        source_project_id: projectId,
+        source_output_file_path: $scope.data.selectedProjectOutputFile,
+        build_id: $scope.data.buildId
       }
-      _setInFlight('create')
-      ide.fileTreeManager
-        .createLinkedFile(name, $scope.parent_folder, provider, payload)
-        .then(function() {
-          _reset({ err: false })
-          return $scope.$emit('done', { name: name })
-        })
-        .catch(function(response) {
-          const { data } = response
-          $scope.error = data
-        })
-    })
+    } else {
+      provider = 'project_file'
+      payload = {
+        source_project_id: projectId,
+        source_entity_path: $scope.data.selectedProjectEntity
+      }
+    }
+    _setInFlight('create')
+    ide.fileTreeManager
+      .createLinkedFile(name, $scope.parent_folder, provider, payload)
+      .then(function() {
+        _reset({ err: false })
+        return $scope.$emit('done', { name: name })
+      })
+      .catch(function(response) {
+        const { data } = response
+        $scope.error = data
+      })
   })
+})
 
-  return App.controller('UrlLinkedFileModalController', function(
-    $scope,
-    ide,
-    $timeout
-  ) {
-    $scope.inputs = {
-      name: '',
-      url: ''
+export default App.controller('UrlLinkedFileModalController', function(
+  $scope,
+  ide,
+  $timeout
+) {
+  $scope.inputs = {
+    name: '',
+    url: ''
+  }
+  $scope.nameChangedByUser = false
+
+  $timeout(() => $scope.$broadcast('open'), 200)
+
+  const validate = function() {
+    const { name, url } = $scope.inputs
+    if (name == null || name.length === 0) {
+      return ($scope.state.valid = false)
+    } else if (url == null || url.length === 0) {
+      return ($scope.state.valid = false)
+    } else {
+      return ($scope.state.valid = true)
     }
-    $scope.nameChangedByUser = false
-
-    $timeout(() => $scope.$broadcast('open'), 200)
-
-    const validate = function() {
-      const { name, url } = $scope.inputs
-      if (name == null || name.length === 0) {
-        return ($scope.state.valid = false)
-      } else if (url == null || url.length === 0) {
-        return ($scope.state.valid = false)
-      } else {
-        return ($scope.state.valid = true)
+  }
+  $scope.$watch('inputs.name', validate)
+  $scope.$watch('inputs.url', validate)
+
+  $scope.$watch('inputs.url', function(url) {
+    if (url != null && url !== '' && !$scope.nameChangedByUser) {
+      url = url.replace('://', '') // Ignore http:// etc
+      const parts = url.split('/').reverse()
+      if (parts.length > 1) {
+        // Wait for at one /
+        return ($scope.inputs.name = parts[0])
       }
     }
-    $scope.$watch('inputs.name', validate)
-    $scope.$watch('inputs.url', validate)
-
-    $scope.$watch('inputs.url', function(url) {
-      if (url != null && url !== '' && !$scope.nameChangedByUser) {
-        url = url.replace('://', '') // Ignore http:// etc
-        const parts = url.split('/').reverse()
-        if (parts.length > 1) {
-          // Wait for at one /
-          return ($scope.inputs.name = parts[0])
-        }
-      }
-    })
+  })
 
-    return $scope.$on('create', function() {
-      const { name, url } = $scope.inputs
-      if (name == null || name.length === 0) {
-        return
-      }
-      if (url == null || url.length === 0) {
-        return
-      }
-      $scope.state.inflight = true
-      return ide.fileTreeManager
-        .createLinkedFile(name, $scope.parent_folder, 'url', { url })
-        .then(function() {
-          $scope.state.inflight = false
-          return $scope.$emit('done', { name: name })
-        })
-        .catch(function(response) {
-          const { data } = response
-          $scope.error = data
-          return ($scope.state.inflight = false)
-        })
-    })
+  return $scope.$on('create', function() {
+    const { name, url } = $scope.inputs
+    if (name == null || name.length === 0) {
+      return
+    }
+    if (url == null || url.length === 0) {
+      return
+    }
+    $scope.state.inflight = true
+    return ide.fileTreeManager
+      .createLinkedFile(name, $scope.parent_folder, 'url', { url })
+      .then(function() {
+        $scope.state.inflight = false
+        return $scope.$emit('done', { name: name })
+      })
+      .catch(function(response) {
+        const { data } = response
+        $scope.error = data
+        return ($scope.state.inflight = false)
+      })
   })
 })
 

+ 121 - 124
services/web/frontend/js/ide/file-tree/controllers/FileTreeEntityController.js

@@ -12,155 +12,152 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base', '../util/iconTypeFromName'], function(
-  App,
-  iconTypeFromName
+import App from '../../../base'
+import iconTypeFromName from '../util/iconTypeFromName'
+App.controller('FileTreeEntityController', function(
+  $scope,
+  ide,
+  $modal,
+  $element
 ) {
-  App.controller('FileTreeEntityController', function(
-    $scope,
-    ide,
-    $modal,
-    $element
-  ) {
-    $scope.select = function(e) {
-      if (e.ctrlKey || e.metaKey) {
-        e.stopPropagation()
-        const initialMultiSelectCount = ide.fileTreeManager.multiSelectedCount()
-        ide.fileTreeManager.toggleMultiSelectEntity($scope.entity) === 0
-        if (initialMultiSelectCount === 0) {
-          // On first multi selection, also include the current active/open file.
-          return ide.fileTreeManager.multiSelectSelectedEntity()
-        }
-      } else {
-        ide.fileTreeManager.selectEntity($scope.entity)
-        return $scope.$emit('entity:selected', $scope.entity)
+  $scope.select = function(e) {
+    if (e.ctrlKey || e.metaKey) {
+      e.stopPropagation()
+      const initialMultiSelectCount = ide.fileTreeManager.multiSelectedCount()
+      ide.fileTreeManager.toggleMultiSelectEntity($scope.entity) === 0
+      if (initialMultiSelectCount === 0) {
+        // On first multi selection, also include the current active/open file.
+        return ide.fileTreeManager.multiSelectSelectedEntity()
       }
+    } else {
+      ide.fileTreeManager.selectEntity($scope.entity)
+      return $scope.$emit('entity:selected', $scope.entity)
     }
-
-    if ($scope.entity.type === 'doc') {
-      $scope.$watch('entity.selected', function(isSelected) {
-        if (isSelected) {
-          $scope.$emit('entity-file:selected', $scope.entity)
-          if (!_isEntryElVisible($element)) {
-            $scope.$applyAsync(function() {
-              $element[0].scrollIntoView()
-            })
-          }
+  }
+
+  if ($scope.entity.type === 'doc') {
+    $scope.$watch('entity.selected', function(isSelected) {
+      if (isSelected) {
+        $scope.$emit('entity-file:selected', $scope.entity)
+        if (!_isEntryElVisible($element)) {
+          $scope.$applyAsync(function() {
+            $element[0].scrollIntoView()
+          })
         }
-      })
-    }
-
-    function _isEntryElVisible($entryEl) {
-      const viewportEl = $('.file-tree-list')
-      const entryElTop = $entryEl.offset().top
-      const entryElBottom = entryElTop + $entryEl.outerHeight()
-      const entryListViewportElTop = viewportEl.offset().top
-      const entryListViewportElBottom =
-        entryListViewportElTop + viewportEl.height()
-
-      return (
-        entryElTop >= entryListViewportElTop &&
-        entryElBottom <= entryListViewportElBottom
-      )
-    }
-
-    $scope.draggableHelper = function() {
-      if (ide.fileTreeManager.multiSelectedCount() > 0) {
-        return $(
-          `<strong style='z-index:100'>${ide.fileTreeManager.multiSelectedCount()} Files</strong>`
-        )
-      } else {
-        return $(`<strong style='z-index:100'>${$scope.entity.name}</strong>`)
       }
+    })
+  }
+
+  function _isEntryElVisible($entryEl) {
+    const viewportEl = $('.file-tree-list')
+    const entryElTop = $entryEl.offset().top
+    const entryElBottom = entryElTop + $entryEl.outerHeight()
+    const entryListViewportElTop = viewportEl.offset().top
+    const entryListViewportElBottom =
+      entryListViewportElTop + viewportEl.height()
+
+    return (
+      entryElTop >= entryListViewportElTop &&
+      entryElBottom <= entryListViewportElBottom
+    )
+  }
+
+  $scope.draggableHelper = function() {
+    if (ide.fileTreeManager.multiSelectedCount() > 0) {
+      return $(
+        `<strong style='z-index:100'>${ide.fileTreeManager.multiSelectedCount()} Files</strong>`
+      )
+    } else {
+      return $(`<strong style='z-index:100'>${$scope.entity.name}</strong>`)
     }
+  }
 
-    $scope.inputs = { name: $scope.entity.name }
-
-    $scope.startRenaming = () => ($scope.entity.renaming = true)
+  $scope.inputs = { name: $scope.entity.name }
 
-    let invalidModalShowing = false
-    $scope.finishRenaming = function() {
-      // avoid double events when blur and on-enter fire together
-      if (!$scope.entity.renaming) {
-        return
-      }
+  $scope.startRenaming = () => ($scope.entity.renaming = true)
 
-      const { name } = $scope.inputs
+  let invalidModalShowing = false
+  $scope.finishRenaming = function() {
+    // avoid double events when blur and on-enter fire together
+    if (!$scope.entity.renaming) {
+      return
+    }
 
-      // validator will set name to undefined for invalid filenames
-      if (name == null) {
-        // Showing the modal blurs the rename box which calls us again
-        // so track this with the invalidModalShowing flag
-        if (invalidModalShowing) {
-          return
-        }
-        invalidModalShowing = true
-        const modal = $modal.open({
-          templateUrl: 'invalidFileNameModalTemplate'
-        })
-        modal.result.then(() => (invalidModalShowing = false))
-        return
-      }
+    const { name } = $scope.inputs
 
-      delete $scope.entity.renaming
-      if (name == null || name.length === 0) {
-        $scope.inputs.name = $scope.entity.name
+    // validator will set name to undefined for invalid filenames
+    if (name == null) {
+      // Showing the modal blurs the rename box which calls us again
+      // so track this with the invalidModalShowing flag
+      if (invalidModalShowing) {
         return
       }
-      return ide.fileTreeManager.renameEntity($scope.entity, name)
+      invalidModalShowing = true
+      const modal = $modal.open({
+        templateUrl: 'invalidFileNameModalTemplate'
+      })
+      modal.result.then(() => (invalidModalShowing = false))
+      return
     }
 
-    $scope.$on('rename:selected', function() {
-      if ($scope.entity.selected) {
-        return $scope.startRenaming()
-      }
-    })
+    delete $scope.entity.renaming
+    if (name == null || name.length === 0) {
+      $scope.inputs.name = $scope.entity.name
+      return
+    }
+    return ide.fileTreeManager.renameEntity($scope.entity, name)
+  }
 
-    $scope.openDeleteModal = function() {
-      let entities
-      if (ide.fileTreeManager.multiSelectedCount() > 0) {
-        entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
-      } else {
-        entities = [$scope.entity]
-      }
-      return $modal.open({
-        templateUrl: 'deleteEntityModalTemplate',
-        controller: 'DeleteEntityModalController',
-        resolve: {
-          entities() {
-            return entities
-          }
-        }
-      })
+  $scope.$on('rename:selected', function() {
+    if ($scope.entity.selected) {
+      return $scope.startRenaming()
     }
+  })
 
-    $scope.$on('delete:selected', function() {
-      if ($scope.entity.selected) {
-        return $scope.openDeleteModal()
+  $scope.openDeleteModal = function() {
+    let entities
+    if (ide.fileTreeManager.multiSelectedCount() > 0) {
+      entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
+    } else {
+      entities = [$scope.entity]
+    }
+    return $modal.open({
+      templateUrl: 'deleteEntityModalTemplate',
+      controller: 'DeleteEntityModalController',
+      resolve: {
+        entities() {
+          return entities
+        }
       }
     })
+  }
 
-    return ($scope.iconTypeFromName = iconTypeFromName)
+  $scope.$on('delete:selected', function() {
+    if ($scope.entity.selected) {
+      return $scope.openDeleteModal()
+    }
   })
 
-  return App.controller('DeleteEntityModalController', function(
-    $scope,
-    ide,
-    $modalInstance,
-    entities
-  ) {
-    $scope.state = { inflight: false }
+  return ($scope.iconTypeFromName = iconTypeFromName)
+})
+
+export default App.controller('DeleteEntityModalController', function(
+  $scope,
+  ide,
+  $modalInstance,
+  entities
+) {
+  $scope.state = { inflight: false }
 
-    $scope.entities = entities
+  $scope.entities = entities
 
-    $scope.delete = function() {
-      $scope.state.inflight = true
-      for (let entity of Array.from($scope.entities)) {
-        ide.fileTreeManager.deleteEntity(entity)
-      }
-      return $modalInstance.close()
+  $scope.delete = function() {
+    $scope.state.inflight = true
+    for (let entity of Array.from($scope.entities)) {
+      ide.fileTreeManager.deleteEntity(entity)
     }
+    return $modalInstance.close()
+  }
 
-    return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
-  })
+  return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
 })

+ 101 - 103
services/web/frontend/js/ide/file-tree/controllers/FileTreeFolderController.js

@@ -12,124 +12,122 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('FileTreeFolderController', function(
-    $scope,
-    ide,
-    $modal,
-    localStorage
-  ) {
-    $scope.expanded =
-      localStorage(`folder.${$scope.entity.id}.expanded`) || false
+import App from '../../../base'
 
-    $scope.toggleExpanded = function() {
-      $scope.expanded = !$scope.expanded
-      $scope._storeCurrentStateInLocalStorage()
-    }
+export default App.controller('FileTreeFolderController', function(
+  $scope,
+  ide,
+  $modal,
+  localStorage
+) {
+  $scope.expanded = localStorage(`folder.${$scope.entity.id}.expanded`) || false
 
-    $scope.$on('entity-file:selected', function() {
-      $scope.expanded = true
-      $scope._storeCurrentStateInLocalStorage()
-    })
+  $scope.toggleExpanded = function() {
+    $scope.expanded = !$scope.expanded
+    $scope._storeCurrentStateInLocalStorage()
+  }
 
-    $scope._storeCurrentStateInLocalStorage = function() {
-      localStorage(`folder.${$scope.entity.id}.expanded`, $scope.expanded)
-    }
+  $scope.$on('entity-file:selected', function() {
+    $scope.expanded = true
+    $scope._storeCurrentStateInLocalStorage()
+  })
 
-    $scope.onDrop = function(events, ui) {
-      let entities
-      if (ide.fileTreeManager.multiSelectedCount()) {
-        entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
-      } else {
-        entities = [$(ui.draggable).scope().entity]
-      }
+  $scope._storeCurrentStateInLocalStorage = function() {
+    localStorage(`folder.${$scope.entity.id}.expanded`, $scope.expanded)
+  }
+
+  $scope.onDrop = function(events, ui) {
+    let entities
+    if (ide.fileTreeManager.multiSelectedCount()) {
+      entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
+    } else {
+      entities = [$(ui.draggable).scope().entity]
+    }
 
-      const ids = $scope.entity.children.map(entity => entity.id)
+    const ids = $scope.entity.children.map(entity => entity.id)
 
-      for (let dropped_entity of Array.from(entities)) {
-        if (!ids.includes(dropped_entity.id)) {
-          try {
-            ide.fileTreeManager.moveEntity(dropped_entity, $scope.entity)
-          } catch (err) {
-            $modal.open({
-              templateUrl: 'duplicateFileModalTemplate',
-              controller: 'DuplicateFileModalController',
-              resolve: {
-                fileName() {
-                  return dropped_entity.name
-                }
+    for (let dropped_entity of Array.from(entities)) {
+      if (!ids.includes(dropped_entity.id)) {
+        try {
+          ide.fileTreeManager.moveEntity(dropped_entity, $scope.entity)
+        } catch (err) {
+          $modal.open({
+            templateUrl: 'duplicateFileModalTemplate',
+            controller: 'DuplicateFileModalController',
+            resolve: {
+              fileName() {
+                return dropped_entity.name
               }
-            })
-          }
+            }
+          })
         }
       }
-      $scope.$digest()
-      // clear highlight explicitly
-      return $('.file-tree-inner .droppable-hover').removeClass(
-        'droppable-hover'
-      )
     }
+    $scope.$digest()
+    // clear highlight explicitly
+    return $('.file-tree-inner .droppable-hover').removeClass('droppable-hover')
+  }
 
-    $scope.orderByFoldersFirst = function(entity) {
-      // We need this here as well as in FileTreeController
-      // since the file-entity diretive creates a new scope
-      // that doesn't inherit from previous scopes.
-      if ((entity != null ? entity.type : undefined) === 'folder') {
-        return '0'
-      }
-      return '1'
+  $scope.orderByFoldersFirst = function(entity) {
+    // We need this here as well as in FileTreeController
+    // since the file-entity diretive creates a new scope
+    // that doesn't inherit from previous scopes.
+    if ((entity != null ? entity.type : undefined) === 'folder') {
+      return '0'
     }
+    return '1'
+  }
 
-    $scope.openNewDocModal = () =>
-      $modal.open({
-        templateUrl: 'newFileModalTemplate',
-        controller: 'NewFileModalController',
-        size: 'lg',
-        resolve: {
-          parent_folder() {
-            return $scope.entity
-          },
-          projectFeatures() {
-            return ide.$scope.project.features
-          },
-          type() {
-            return 'doc'
-          },
-          userFeatures() {
-            return ide.$scope.user.features
-          }
+  $scope.openNewDocModal = () =>
+    $modal.open({
+      templateUrl: 'newFileModalTemplate',
+      controller: 'NewFileModalController',
+      size: 'lg',
+      resolve: {
+        parent_folder() {
+          return $scope.entity
+        },
+        projectFeatures() {
+          return ide.$scope.project.features
+        },
+        type() {
+          return 'doc'
+        },
+        userFeatures() {
+          return ide.$scope.user.features
         }
-      })
+      }
+    })
 
-    $scope.openNewFolderModal = () =>
-      $modal.open({
-        templateUrl: 'newFolderModalTemplate',
-        controller: 'NewFolderModalController',
-        resolve: {
-          parent_folder() {
-            return $scope.entity
-          }
+  $scope.openNewFolderModal = () =>
+    $modal.open({
+      templateUrl: 'newFolderModalTemplate',
+      controller: 'NewFolderModalController',
+      resolve: {
+        parent_folder() {
+          return $scope.entity
         }
-      })
+      }
+    })
 
-    return ($scope.openUploadFileModal = () =>
-      $modal.open({
-        templateUrl: 'newFileModalTemplate',
-        controller: 'NewFileModalController',
-        size: 'lg',
-        resolve: {
-          parent_folder() {
-            return $scope.entity
-          },
-          projectFeatures() {
-            return ide.$scope.project.features
-          },
-          type() {
-            return 'upload'
-          },
-          userFeatures() {
-            return ide.$scope.user.features
-          }
+  return ($scope.openUploadFileModal = () =>
+    $modal.open({
+      templateUrl: 'newFileModalTemplate',
+      controller: 'NewFileModalController',
+      size: 'lg',
+      resolve: {
+        parent_folder() {
+          return $scope.entity
+        },
+        projectFeatures() {
+          return ide.$scope.project.features
+        },
+        type() {
+          return 'upload'
+        },
+        userFeatures() {
+          return ide.$scope.user.features
         }
-      }))
-  }))
+      }
+    }))
+})

+ 35 - 32
services/web/frontend/js/ide/file-tree/controllers/FileTreeRootFolderController.js

@@ -11,40 +11,43 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('FileTreeRootFolderController', function($scope, $modal, ide) {
-    const { rootFolder } = $scope
-    return ($scope.onDrop = function(events, ui) {
-      let entities
-      if (ide.fileTreeManager.multiSelectedCount()) {
-        entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
-      } else {
-        entities = [$(ui.draggable).scope().entity]
-      }
+import App from '../../../base'
+
+export default App.controller('FileTreeRootFolderController', function(
+  $scope,
+  $modal,
+  ide
+) {
+  const { rootFolder } = $scope
+  return ($scope.onDrop = function(events, ui) {
+    let entities
+    if (ide.fileTreeManager.multiSelectedCount()) {
+      entities = ide.fileTreeManager.getMultiSelectedEntityChildNodes()
+    } else {
+      entities = [$(ui.draggable).scope().entity]
+    }
 
-      const ids = rootFolder.children.map(entity => entity.id)
+    const ids = rootFolder.children.map(entity => entity.id)
 
-      for (let dropped_entity of Array.from(entities)) {
-        if (!ids.includes(dropped_entity.id)) {
-          try {
-            ide.fileTreeManager.moveEntity(dropped_entity, rootFolder)
-          } catch (err) {
-            $modal.open({
-              templateUrl: 'duplicateFileModalTemplate',
-              controller: 'DuplicateFileModalController',
-              resolve: {
-                fileName() {
-                  return dropped_entity.name
-                }
+    for (let dropped_entity of Array.from(entities)) {
+      if (!ids.includes(dropped_entity.id)) {
+        try {
+          ide.fileTreeManager.moveEntity(dropped_entity, rootFolder)
+        } catch (err) {
+          $modal.open({
+            templateUrl: 'duplicateFileModalTemplate',
+            controller: 'DuplicateFileModalController',
+            resolve: {
+              fileName() {
+                return dropped_entity.name
               }
-            })
-          }
+            }
+          })
         }
       }
-      $scope.$digest()
-      // clear highlight explicitly
-      return $('.file-tree-inner .droppable-hover').removeClass(
-        'droppable-hover'
-      )
-    })
-  }))
+    }
+    $scope.$digest()
+    // clear highlight explicitly
+    return $('.file-tree-inner .droppable-hover').removeClass('droppable-hover')
+  })
+})

+ 16 - 15
services/web/frontend/js/ide/file-tree/directives/draggable.js

@@ -7,18 +7,19 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.directive('draggable', () => ({
-    link(scope, element, attrs) {
-      return scope.$watch(attrs.draggable, function(draggable) {
-        if (draggable) {
-          return element.draggable({
-            delay: 250,
-            opacity: 0.95,
-            scroll: true,
-            helper: scope.$eval(attrs.draggableHelper)
-          })
-        }
-      })
-    }
-  })))
+import App from '../../../base'
+
+export default App.directive('draggable', () => ({
+  link(scope, element, attrs) {
+    return scope.$watch(attrs.draggable, function(draggable) {
+      if (draggable) {
+        return element.draggable({
+          delay: 250,
+          opacity: 0.95,
+          scroll: true,
+          helper: scope.$eval(attrs.draggableHelper)
+        })
+      }
+    })
+  }
+}))

+ 17 - 16
services/web/frontend/js/ide/file-tree/directives/droppable.js

@@ -7,19 +7,20 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.directive('droppable', () => ({
-    link(scope, element, attrs) {
-      return scope.$watch(attrs.droppable, function(droppable) {
-        if (droppable) {
-          return element.droppable({
-            greedy: true,
-            hoverClass: 'droppable-hover',
-            tolerance: 'pointer',
-            accept: attrs.accept,
-            drop: scope.$eval(attrs.onDropCallback)
-          })
-        }
-      })
-    }
-  })))
+import App from '../../../base'
+
+export default App.directive('droppable', () => ({
+  link(scope, element, attrs) {
+    return scope.$watch(attrs.droppable, function(droppable) {
+      if (droppable) {
+        return element.droppable({
+          greedy: true,
+          hoverClass: 'droppable-hover',
+          tolerance: 'pointer',
+          accept: attrs.accept,
+          drop: scope.$eval(attrs.onDropCallback)
+        })
+      }
+    })
+  }
+}))

+ 31 - 30
services/web/frontend/js/ide/file-tree/directives/fileEntity.js

@@ -10,34 +10,35 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.directive('fileEntity', RecursionHelper => ({
-    restrict: 'E',
-    scope: {
-      entity: '=',
-      permissions: '='
-    },
-    templateUrl: 'entityListItemTemplate',
-    compile(element) {
-      return RecursionHelper.compile(element, function(
-        scope,
-        element,
-        attrs,
-        ctrl
-      ) {
-        // Don't freak out if we're already in an apply callback
-        scope.$originalApply = scope.$apply
-        return (scope.$apply = function(fn) {
-          if (fn == null) {
-            fn = function() {}
-          }
-          const phase = this.$root.$$phase
-          if (phase === '$apply' || phase === '$digest') {
-            return fn()
-          } else {
-            return this.$originalApply(fn)
-          }
-        })
+import App from '../../../base'
+
+export default App.directive('fileEntity', RecursionHelper => ({
+  restrict: 'E',
+  scope: {
+    entity: '=',
+    permissions: '='
+  },
+  templateUrl: 'entityListItemTemplate',
+  compile(element) {
+    return RecursionHelper.compile(element, function(
+      scope,
+      element,
+      attrs,
+      ctrl
+    ) {
+      // Don't freak out if we're already in an apply callback
+      scope.$originalApply = scope.$apply
+      return (scope.$apply = function(fn) {
+        if (fn == null) {
+          fn = function() {}
+        }
+        const phase = this.$root.$$phase
+        if (phase === '$apply' || phase === '$digest') {
+          return fn()
+        } else {
+          return this.$originalApply(fn)
+        }
       })
-    }
-  })))
+    })
+  }
+}))

+ 14 - 11
services/web/frontend/js/ide/file-tree/util/fileOperationI18nNames.js

@@ -1,11 +1,14 @@
-define([], function() {
-  if (window.fileActionI18n !== undefined) {
-    return window.fileActionI18n
-  }
-  return {
-    edited: 'edited',
-    renamed: 'renamed',
-    created: 'created',
-    deleted: 'deleted'
-  }
-})
+let fileActionI18n
+
+if (window.fileActionI18n !== undefined) {
+  fileActionI18n = window.fileActionI18n
+}
+
+fileActionI18n = {
+  edited: 'edited',
+  renamed: 'renamed',
+  created: 'created',
+  deleted: 'deleted'
+}
+
+export default fileActionI18n

+ 16 - 16
services/web/frontend/js/ide/file-tree/util/iconTypeFromName.js

@@ -10,23 +10,23 @@
  * DS103: Rewrite code to no longer use __guard__
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  let iconTypeFromName
-  return (iconTypeFromName = function(name) {
-    const ext = __guard__(name.split('.').pop(), x => x.toLowerCase())
-    if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
-      return 'image'
-    } else if (['csv', 'xls', 'xlsx'].includes(ext)) {
-      return 'table'
-    } else if (['py', 'r'].includes(ext)) {
-      return 'file-text'
-    } else if (['bib'].includes(ext)) {
-      return 'book'
-    } else {
-      return 'file'
-    }
-  })
+let iconTypeFromName
+
+export default (iconTypeFromName = function(name) {
+  const ext = __guard__(name.split('.').pop(), x => x.toLowerCase())
+  if (['png', 'pdf', 'jpg', 'jpeg', 'gif'].includes(ext)) {
+    return 'image'
+  } else if (['csv', 'xls', 'xlsx'].includes(ext)) {
+    return 'table'
+  } else if (['py', 'r'].includes(ext)) {
+    return 'file-text'
+  } else if (['bib'].includes(ext)) {
+    return 'book'
+  } else {
+    return 'file'
+  }
 })
+
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null
     ? transform(value)

+ 20 - 19
services/web/frontend/js/ide/files/services/files.js

@@ -1,20 +1,21 @@
-define(['../../../base'], App =>
-  App.factory('files', function(ide) {
-    const Files = {
-      getTeXFiles() {
-        let texFiles = []
-        ide.fileTreeManager.forEachEntity(function(entity, _folder, path) {
-          if (
-            entity.type === 'doc' &&
-            /.*\.(tex|md|txt|tikz)/.test(entity.name)
-          ) {
-            const cloned = _.clone(entity)
-            cloned.path = path
-            texFiles.push(cloned)
-          }
-        })
-        return texFiles
-      }
+import App from '../../../base'
+
+export default App.factory('files', function(ide) {
+  const Files = {
+    getTeXFiles() {
+      let texFiles = []
+      ide.fileTreeManager.forEachEntity(function(entity, _folder, path) {
+        if (
+          entity.type === 'doc' &&
+          /.*\.(tex|md|txt|tikz)/.test(entity.name)
+        ) {
+          const cloned = _.clone(entity)
+          cloned.path = path
+          texFiles.push(cloned)
+        }
+      })
+      return texFiles
     }
-    return Files
-  }))
+  }
+  return Files
+})

+ 24 - 23
services/web/frontend/js/ide/graphics/services/graphics.js

@@ -10,31 +10,32 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.factory('graphics', function(ide) {
-    const Graphics = {
-      getGraphicsFiles() {
-        const graphicsFiles = []
-        ide.fileTreeManager.forEachEntity(function(entity, folder, path) {
-          if (
-            entity.type === 'file' &&
-            __guardMethod__(
-              entity != null ? entity.name : undefined,
-              'match',
-              o => o.match(/.*\.(png|jpg|jpeg|pdf|eps)/i)
-            )
-          ) {
-            const cloned = _.clone(entity)
-            cloned.path = path
-            return graphicsFiles.push(cloned)
-          }
-        })
-        return graphicsFiles
-      }
+import App from '../../../base'
+
+export default App.factory('graphics', function(ide) {
+  const Graphics = {
+    getGraphicsFiles() {
+      const graphicsFiles = []
+      ide.fileTreeManager.forEachEntity(function(entity, folder, path) {
+        if (
+          entity.type === 'file' &&
+          __guardMethod__(
+            entity != null ? entity.name : undefined,
+            'match',
+            o => o.match(/.*\.(png|jpg|jpeg|pdf|eps)/i)
+          )
+        ) {
+          const cloned = _.clone(entity)
+          cloned.path = path
+          return graphicsFiles.push(cloned)
+        }
+      })
+      return graphicsFiles
     }
+  }
 
-    return Graphics
-  }))
+  return Graphics
+})
 
 function __guardMethod__(obj, methodName, transform) {
   if (

+ 318 - 325
services/web/frontend/js/ide/history/HistoryManager.js

@@ -14,385 +14,378 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  'moment',
-  '../colors/ColorManager',
-  './util/displayNameForUser',
-  './controllers/HistoryListController',
-  './controllers/HistoryDiffController',
-  './directives/infiniteScroll'
-], function(moment, ColorManager, displayNameForUser) {
-  let HistoryManager
-  return (HistoryManager = (function() {
-    HistoryManager = class HistoryManager {
-      static initClass() {
-        this.prototype.BATCH_SIZE = 10
+import moment from 'moment'
+import ColorManager from '../colors/ColorManager'
+import displayNameForUser from './util/displayNameForUser'
+import './controllers/HistoryListController'
+import './controllers/HistoryDiffController'
+import './directives/infiniteScroll'
+let HistoryManager
+
+export default (HistoryManager = (function() {
+  HistoryManager = class HistoryManager {
+    static initClass() {
+      this.prototype.BATCH_SIZE = 10
+    }
+    constructor(ide, $scope) {
+      this.ide = ide
+      this.$scope = $scope
+      this.reset()
+
+      this.$scope.toggleHistory = () => {
+        if (this.$scope.ui.view === 'history') {
+          return this.hide()
+        } else {
+          return this.show()
+        }
       }
-      constructor(ide, $scope) {
-        this.ide = ide
-        this.$scope = $scope
-        this.reset()
-
-        this.$scope.toggleHistory = () => {
-          if (this.$scope.ui.view === 'history') {
-            return this.hide()
-          } else {
-            return this.show()
-          }
+
+      this.$scope.$watch('history.selection.updates', updates => {
+        if (updates != null && updates.length > 0) {
+          this._selectDocFromUpdates()
+          return this.reloadDiff()
         }
+      })
 
-        this.$scope.$watch('history.selection.updates', updates => {
-          if (updates != null && updates.length > 0) {
-            this._selectDocFromUpdates()
-            return this.reloadDiff()
-          }
-        })
+      this.$scope.$on('entity:selected', (event, entity) => {
+        if (this.$scope.ui.view === 'history' && entity.type === 'doc') {
+          this.$scope.history.selection.doc = entity
+          return this.reloadDiff()
+        }
+      })
+    }
 
-        this.$scope.$on('entity:selected', (event, entity) => {
-          if (this.$scope.ui.view === 'history' && entity.type === 'doc') {
-            this.$scope.history.selection.doc = entity
-            return this.reloadDiff()
+    show() {
+      this.$scope.ui.view = 'history'
+      return this.reset()
+    }
+
+    hide() {
+      this.$scope.ui.view = 'editor'
+      // Make sure we run the 'open' logic for whatever is currently selected
+      return this.$scope.$emit(
+        'entity:selected',
+        this.ide.fileTreeManager.findSelectedEntity()
+      )
+    }
+
+    reset() {
+      return (this.$scope.history = {
+        updates: [],
+        nextBeforeTimestamp: null,
+        atEnd: false,
+        selection: {
+          updates: [],
+          doc: null,
+          range: {
+            fromV: null,
+            toV: null,
+            start_ts: null,
+            end_ts: null
           }
-        })
-      }
+        },
+        diff: null
+      })
+    }
 
-      show() {
-        this.$scope.ui.view = 'history'
-        return this.reset()
+    autoSelectRecentUpdates() {
+      if (this.$scope.history.updates.length === 0) {
+        return
       }
 
-      hide() {
-        this.$scope.ui.view = 'editor'
-        // Make sure we run the 'open' logic for whatever is currently selected
-        return this.$scope.$emit(
-          'entity:selected',
-          this.ide.fileTreeManager.findSelectedEntity()
-        )
-      }
+      this.$scope.history.updates[0].selectedTo = true
 
-      reset() {
-        return (this.$scope.history = {
-          updates: [],
-          nextBeforeTimestamp: null,
-          atEnd: false,
-          selection: {
-            updates: [],
-            doc: null,
-            range: {
-              fromV: null,
-              toV: null,
-              start_ts: null,
-              end_ts: null
-            }
-          },
-          diff: null
-        })
+      let indexOfLastUpdateNotByMe = 0
+      for (let i = 0; i < this.$scope.history.updates.length; i++) {
+        const update = this.$scope.history.updates[i]
+        if (this._updateContainsUserId(update, this.$scope.user.id)) {
+          break
+        }
+        indexOfLastUpdateNotByMe = i
       }
 
-      autoSelectRecentUpdates() {
-        if (this.$scope.history.updates.length === 0) {
-          return
+      return (this.$scope.history.updates[
+        indexOfLastUpdateNotByMe
+      ].selectedFrom = true)
+    }
+    fetchNextBatchOfUpdates() {
+      let url = `/project/${this.ide.project_id}/updates?min_count=${
+        this.BATCH_SIZE
+      }`
+      if (this.$scope.history.nextBeforeTimestamp != null) {
+        url += `&before=${this.$scope.history.nextBeforeTimestamp}`
+      }
+      this.$scope.history.loading = true
+      return this.ide.$http.get(url).then(response => {
+        const { data } = response
+        this._loadUpdates(data.updates)
+        this.$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
+        if (data.nextBeforeTimestamp == null) {
+          this.$scope.history.atEnd = true
         }
+        return (this.$scope.history.loading = false)
+      })
+    }
 
-        this.$scope.history.updates[0].selectedTo = true
+    reloadDiff() {
+      let { diff } = this.$scope.history
+      const { updates, doc } = this.$scope.history.selection
+      const {
+        fromV,
+        toV,
+        start_ts,
+        end_ts
+      } = this._calculateRangeFromSelection()
+
+      if (doc == null) {
+        return
+      }
 
-        let indexOfLastUpdateNotByMe = 0
-        for (let i = 0; i < this.$scope.history.updates.length; i++) {
-          const update = this.$scope.history.updates[i]
-          if (this._updateContainsUserId(update, this.$scope.user.id)) {
-            break
-          }
-          indexOfLastUpdateNotByMe = i
-        }
+      if (
+        diff != null &&
+        diff.doc === doc &&
+        diff.fromV === fromV &&
+        diff.toV === toV
+      ) {
+        return
+      }
 
-        return (this.$scope.history.updates[
-          indexOfLastUpdateNotByMe
-        ].selectedFrom = true)
+      this.$scope.history.diff = diff = {
+        fromV,
+        toV,
+        start_ts,
+        end_ts,
+        doc,
+        error: false,
+        pathname: doc.name
       }
-      fetchNextBatchOfUpdates() {
-        let url = `/project/${this.ide.project_id}/updates?min_count=${
-          this.BATCH_SIZE
-        }`
-        if (this.$scope.history.nextBeforeTimestamp != null) {
-          url += `&before=${this.$scope.history.nextBeforeTimestamp}`
+
+      if (!doc.deleted) {
+        diff.loading = true
+        let url = `/project/${this.$scope.project_id}/doc/${diff.doc.id}/diff`
+        if (diff.fromV != null && diff.toV != null) {
+          url += `?from=${diff.fromV}&to=${diff.toV}`
         }
-        this.$scope.history.loading = true
-        return this.ide.$http.get(url).then(response => {
-          const { data } = response
-          this._loadUpdates(data.updates)
-          this.$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
-          if (data.nextBeforeTimestamp == null) {
-            this.$scope.history.atEnd = true
-          }
-          return (this.$scope.history.loading = false)
-        })
+
+        return this.ide.$http
+          .get(url)
+          .then(response => {
+            const { data } = response
+            diff.loading = false
+            const { text, highlights } = this._parseDiff(data)
+            diff.text = text
+            return (diff.highlights = highlights)
+          })
+          .catch(function() {
+            diff.loading = false
+            return (diff.error = true)
+          })
+      } else {
+        diff.deleted = true
+        diff.restoreInProgress = false
+        diff.restoreDeletedSuccess = false
+        return (diff.restoredDocNewId = null)
       }
+    }
 
-      reloadDiff() {
-        let { diff } = this.$scope.history
-        const { updates, doc } = this.$scope.history.selection
-        const {
-          fromV,
-          toV,
-          start_ts,
-          end_ts
-        } = this._calculateRangeFromSelection()
-
-        if (doc == null) {
-          return
-        }
+    restoreDeletedDoc(doc) {
+      const url = `/project/${this.$scope.project_id}/doc/${doc.id}/restore`
+      return this.ide.$http.post(url, {
+        name: doc.name,
+        _csrf: window.csrfToken
+      })
+    }
 
-        if (
-          diff != null &&
-          diff.doc === doc &&
-          diff.fromV === fromV &&
-          diff.toV === toV
-        ) {
-          return
-        }
+    restoreDiff(diff) {
+      const url = `/project/${this.$scope.project_id}/doc/${
+        diff.doc.id
+      }/version/${diff.fromV}/restore`
+      return this.ide.$http.post(url, { _csrf: window.csrfToken })
+    }
 
-        this.$scope.history.diff = diff = {
-          fromV,
-          toV,
-          start_ts,
-          end_ts,
-          doc,
-          error: false,
-          pathname: doc.name
+    _parseDiff(diff) {
+      let row = 0
+      let column = 0
+      const highlights = []
+      let text = ''
+      const iterable = diff.diff || []
+      for (let i = 0; i < iterable.length; i++) {
+        var endColumn, endRow
+        const entry = iterable[i]
+        let content = entry.u || entry.i || entry.d
+        if (!content) {
+          content = ''
         }
+        text += content
+        const lines = content.split('\n')
+        const startRow = row
+        const startColumn = column
+        if (lines.length > 1) {
+          endRow = startRow + lines.length - 1
+          endColumn = lines[lines.length - 1].length
+        } else {
+          endRow = startRow
+          endColumn = startColumn + lines[0].length
+        }
+        row = endRow
+        column = endColumn
 
-        if (!doc.deleted) {
-          diff.loading = true
-          let url = `/project/${this.$scope.project_id}/doc/${diff.doc.id}/diff`
-          if (diff.fromV != null && diff.toV != null) {
-            url += `?from=${diff.fromV}&to=${diff.toV}`
+        const range = {
+          start: {
+            row: startRow,
+            column: startColumn
+          },
+          end: {
+            row: endRow,
+            column: endColumn
           }
+        }
 
-          return this.ide.$http
-            .get(url)
-            .then(response => {
-              const { data } = response
-              diff.loading = false
-              const { text, highlights } = this._parseDiff(data)
-              diff.text = text
-              return (diff.highlights = highlights)
+        if (entry.i != null || entry.d != null) {
+          const name = displayNameForUser(entry.meta.user)
+          const date = moment(entry.meta.end_ts).format('Do MMM YYYY, h:mm a')
+          if (entry.i != null) {
+            highlights.push({
+              label: `Added by ${name} on ${date}`,
+              highlight: range,
+              hue: ColorManager.getHueForUserId(
+                entry.meta.user != null ? entry.meta.user.id : undefined
+              )
             })
-            .catch(function() {
-              diff.loading = false
-              return (diff.error = true)
+          } else if (entry.d != null) {
+            highlights.push({
+              label: `Deleted by ${name} on ${date}`,
+              strikeThrough: range,
+              hue: ColorManager.getHueForUserId(
+                entry.meta.user != null ? entry.meta.user.id : undefined
+              )
             })
-        } else {
-          diff.deleted = true
-          diff.restoreInProgress = false
-          diff.restoreDeletedSuccess = false
-          return (diff.restoredDocNewId = null)
+          }
         }
       }
 
-      restoreDeletedDoc(doc) {
-        const url = `/project/${this.$scope.project_id}/doc/${doc.id}/restore`
-        return this.ide.$http.post(url, {
-          name: doc.name,
-          _csrf: window.csrfToken
-        })
-      }
+      return { text, highlights }
+    }
 
-      restoreDiff(diff) {
-        const url = `/project/${this.$scope.project_id}/doc/${
-          diff.doc.id
-        }/version/${diff.fromV}/restore`
-        return this.ide.$http.post(url, { _csrf: window.csrfToken })
+    _loadUpdates(updates) {
+      if (updates == null) {
+        updates = []
       }
+      let previousUpdate = this.$scope.history.updates[
+        this.$scope.history.updates.length - 1
+      ]
+
+      for (let update of Array.from(updates)) {
+        update.pathnames = [] // Used for display
+        const object = update.docs || {}
+        for (let doc_id in object) {
+          const doc = object[doc_id]
+          doc.entity = this.ide.fileTreeManager.findEntityById(doc_id, {
+            includeDeleted: true
+          })
+          update.pathnames.push(doc.entity.name)
+        }
 
-      _parseDiff(diff) {
-        let row = 0
-        let column = 0
-        const highlights = []
-        let text = ''
-        const iterable = diff.diff || []
-        for (let i = 0; i < iterable.length; i++) {
-          var endColumn, endRow
-          const entry = iterable[i]
-          let content = entry.u || entry.i || entry.d
-          if (!content) {
-            content = ''
-          }
-          text += content
-          const lines = content.split('\n')
-          const startRow = row
-          const startColumn = column
-          if (lines.length > 1) {
-            endRow = startRow + lines.length - 1
-            endColumn = lines[lines.length - 1].length
-          } else {
-            endRow = startRow
-            endColumn = startColumn + lines[0].length
-          }
-          row = endRow
-          column = endColumn
-
-          const range = {
-            start: {
-              row: startRow,
-              column: startColumn
-            },
-            end: {
-              row: endRow,
-              column: endColumn
-            }
-          }
-
-          if (entry.i != null || entry.d != null) {
-            const name = displayNameForUser(entry.meta.user)
-            const date = moment(entry.meta.end_ts).format('Do MMM YYYY, h:mm a')
-            if (entry.i != null) {
-              highlights.push({
-                label: `Added by ${name} on ${date}`,
-                highlight: range,
-                hue: ColorManager.getHueForUserId(
-                  entry.meta.user != null ? entry.meta.user.id : undefined
-                )
-              })
-            } else if (entry.d != null) {
-              highlights.push({
-                label: `Deleted by ${name} on ${date}`,
-                strikeThrough: range,
-                hue: ColorManager.getHueForUserId(
-                  entry.meta.user != null ? entry.meta.user.id : undefined
-                )
-              })
-            }
+        for (let user of Array.from(update.meta.users || [])) {
+          if (user != null) {
+            user.hue = ColorManager.getHueForUserId(user.id)
           }
         }
 
-        return { text, highlights }
-      }
-
-      _loadUpdates(updates) {
-        if (updates == null) {
-          updates = []
+        if (
+          previousUpdate == null ||
+          !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
+        ) {
+          update.meta.first_in_day = true
         }
-        let previousUpdate = this.$scope.history.updates[
-          this.$scope.history.updates.length - 1
-        ]
-
-        for (let update of Array.from(updates)) {
-          update.pathnames = [] // Used for display
-          const object = update.docs || {}
-          for (let doc_id in object) {
-            const doc = object[doc_id]
-            doc.entity = this.ide.fileTreeManager.findEntityById(doc_id, {
-              includeDeleted: true
-            })
-            update.pathnames.push(doc.entity.name)
-          }
-
-          for (let user of Array.from(update.meta.users || [])) {
-            if (user != null) {
-              user.hue = ColorManager.getHueForUserId(user.id)
-            }
-          }
-
-          if (
-            previousUpdate == null ||
-            !moment(previousUpdate.meta.end_ts).isSame(
-              update.meta.end_ts,
-              'day'
-            )
-          ) {
-            update.meta.first_in_day = true
-          }
 
-          update.selectedFrom = false
-          update.selectedTo = false
-          update.inSelection = false
+        update.selectedFrom = false
+        update.selectedTo = false
+        update.inSelection = false
 
-          previousUpdate = update
-        }
+        previousUpdate = update
+      }
 
-        const firstLoad = this.$scope.history.updates.length === 0
+      const firstLoad = this.$scope.history.updates.length === 0
 
-        this.$scope.history.updates = this.$scope.history.updates.concat(
-          updates
-        )
+      this.$scope.history.updates = this.$scope.history.updates.concat(updates)
 
-        if (firstLoad) {
-          return this.autoSelectRecentUpdates()
-        }
+      if (firstLoad) {
+        return this.autoSelectRecentUpdates()
       }
+    }
 
-      _calculateRangeFromSelection() {
-        let end_ts, start_ts, toV
-        let fromV = (toV = start_ts = end_ts = null)
-
-        const selected_doc_id =
-          this.$scope.history.selection.doc != null
-            ? this.$scope.history.selection.doc.id
-            : undefined
-
-        for (let update of Array.from(
-          this.$scope.history.selection.updates || []
-        )) {
-          for (let doc_id in update.docs) {
-            const doc = update.docs[doc_id]
-            if (doc_id === selected_doc_id) {
-              if (fromV != null && toV != null) {
-                fromV = Math.min(fromV, doc.fromV)
-                toV = Math.max(toV, doc.toV)
-                start_ts = Math.min(start_ts, update.meta.start_ts)
-                end_ts = Math.max(end_ts, update.meta.end_ts)
-              } else {
-                ;({ fromV } = doc)
-                ;({ toV } = doc)
-                ;({ start_ts } = update.meta)
-                ;({ end_ts } = update.meta)
-              }
-              break
+    _calculateRangeFromSelection() {
+      let end_ts, start_ts, toV
+      let fromV = (toV = start_ts = end_ts = null)
+
+      const selected_doc_id =
+        this.$scope.history.selection.doc != null
+          ? this.$scope.history.selection.doc.id
+          : undefined
+
+      for (let update of Array.from(
+        this.$scope.history.selection.updates || []
+      )) {
+        for (let doc_id in update.docs) {
+          const doc = update.docs[doc_id]
+          if (doc_id === selected_doc_id) {
+            if (fromV != null && toV != null) {
+              fromV = Math.min(fromV, doc.fromV)
+              toV = Math.max(toV, doc.toV)
+              start_ts = Math.min(start_ts, update.meta.start_ts)
+              end_ts = Math.max(end_ts, update.meta.end_ts)
+            } else {
+              ;({ fromV } = doc)
+              ;({ toV } = doc)
+              ;({ start_ts } = update.meta)
+              ;({ end_ts } = update.meta)
             }
+            break
           }
         }
-
-        return { fromV, toV, start_ts, end_ts }
       }
 
-      // Set the track changes selected doc to one of the docs in the range
-      // of currently selected updates. If we already have a selected doc
-      // then prefer this one if present.
-      _selectDocFromUpdates() {
-        let doc, doc_id
-        const affected_docs = {}
-        for (let update of Array.from(this.$scope.history.selection.updates)) {
-          for (doc_id in update.docs) {
-            doc = update.docs[doc_id]
-            affected_docs[doc_id] = doc.entity
-          }
-        }
+      return { fromV, toV, start_ts, end_ts }
+    }
 
-        let selected_doc = this.$scope.history.selection.doc
-        if (selected_doc != null && affected_docs[selected_doc.id] != null) {
-          // Selected doc is already open
-        } else {
-          for (doc_id in affected_docs) {
-            doc = affected_docs[doc_id]
-            selected_doc = doc
-            break
-          }
+    // Set the track changes selected doc to one of the docs in the range
+    // of currently selected updates. If we already have a selected doc
+    // then prefer this one if present.
+    _selectDocFromUpdates() {
+      let doc, doc_id
+      const affected_docs = {}
+      for (let update of Array.from(this.$scope.history.selection.updates)) {
+        for (doc_id in update.docs) {
+          doc = update.docs[doc_id]
+          affected_docs[doc_id] = doc.entity
         }
+      }
 
-        this.$scope.history.selection.doc = selected_doc
-        return this.ide.fileTreeManager.selectEntity(selected_doc)
+      let selected_doc = this.$scope.history.selection.doc
+      if (selected_doc != null && affected_docs[selected_doc.id] != null) {
+        // Selected doc is already open
+      } else {
+        for (doc_id in affected_docs) {
+          doc = affected_docs[doc_id]
+          selected_doc = doc
+          break
+        }
       }
 
-      _updateContainsUserId(update, user_id) {
-        for (let user of Array.from(update.meta.users)) {
-          if ((user != null ? user.id : undefined) === user_id) {
-            return true
-          }
+      this.$scope.history.selection.doc = selected_doc
+      return this.ide.fileTreeManager.selectEntity(selected_doc)
+    }
+
+    _updateContainsUserId(update, user_id) {
+      for (let user of Array.from(update.meta.users)) {
+        if ((user != null ? user.id : undefined) === user_id) {
+          return true
         }
-        return false
       }
+      return false
     }
-    HistoryManager.initClass()
-    return HistoryManager
-  })())
-})
+  }
+  HistoryManager.initClass()
+  return HistoryManager
+})())

+ 831 - 842
services/web/frontend/js/ide/history/HistoryV2Manager.js

@@ -16,162 +16,123 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  'moment',
-  '../colors/ColorManager',
-  './util/displayNameForUser',
-  './util/HistoryViewModes',
-  './controllers/HistoryV2ListController',
-  './controllers/HistoryV2FileTreeController',
-  './controllers/HistoryV2ToolbarController',
-  './controllers/HistoryV2AddLabelModalController',
-  './controllers/HistoryV2DeleteLabelModalController',
-  './directives/infiniteScroll',
-  './directives/historyDraggableBoundary',
-  './directives/historyDroppableArea',
-  './components/historyEntriesList',
-  './components/historyEntry',
-  './components/historyLabelsList',
-  './components/historyLabel',
-  './components/historyFileTree',
-  './components/historyFileEntity'
-], function(moment, ColorManager, displayNameForUser, HistoryViewModes) {
-  let HistoryManager
-  return (HistoryManager = (function() {
-    HistoryManager = class HistoryManager {
-      static initClass() {
-        this.prototype.MAX_RECENT_UPDATES_TO_SELECT = 5
-        this.prototype.BATCH_SIZE = 10
-      }
-      constructor(ide, $scope, localStorage) {
-        this.labelCurrentVersion = this.labelCurrentVersion.bind(this)
-        this.deleteLabel = this.deleteLabel.bind(this)
-        this._addLabelLocally = this._addLabelLocally.bind(this)
-        this.ide = ide
-        this.$scope = $scope
-        this.localStorage = localStorage
-        this.$scope.HistoryViewModes = HistoryViewModes
-        this._localStorageViewModeProjKey = `history.userPrefs.viewMode.${
-          $scope.project_id
-        }`
-        this._localStorageShowOnlyLabelsProjKey = `history.userPrefs.showOnlyLabels.${
-          $scope.project_id
-        }`
-        this._previouslySelectedPathname = null
-        this._loadFileTreeRequestCanceller = null
-        this.hardReset()
-
-        this.$scope.toggleHistory = () => {
-          if (this.$scope.ui.view === 'history') {
-            this.hide()
-          } else {
-            this.show()
-            this._handleHistoryUIStateChange()
-          }
-          this.ide.$timeout(() => {
-            this.$scope.$broadcast('history:toggle')
-          }, 0)
-        }
-
-        this.$scope.isHistoryLoading = () => {
-          let selection = this.$scope.history.selection
-          return (
-            this.$scope.history.loadingFileTree ||
-            (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME &&
-              selection.file &&
-              selection.file.loading) ||
-            (this.$scope.history.viewMode === HistoryViewModes.COMPARE &&
-              selection.diff &&
-              selection.diff.loading)
-          )
-        }
-      }
-
-      show() {
-        this.$scope.ui.view = 'history'
-        this.hardReset()
-        if (this.$scope.history.showOnlyLabels) {
-          this.fetchNextBatchOfUpdates()
+import moment from 'moment'
+import ColorManager from '../colors/ColorManager'
+import displayNameForUser from './util/displayNameForUser'
+import HistoryViewModes from './util/HistoryViewModes'
+import './controllers/HistoryV2ListController'
+import './controllers/HistoryV2FileTreeController'
+import './controllers/HistoryV2ToolbarController'
+import './controllers/HistoryV2AddLabelModalController'
+import './controllers/HistoryV2DeleteLabelModalController'
+import './directives/infiniteScroll'
+import './directives/historyDraggableBoundary'
+import './directives/historyDroppableArea'
+import './components/historyEntriesList'
+import './components/historyEntry'
+import './components/historyLabelsList'
+import './components/historyLabel'
+import './components/historyFileTree'
+import './components/historyFileEntity'
+let HistoryManager
+
+export default (HistoryManager = (function() {
+  HistoryManager = class HistoryManager {
+    static initClass() {
+      this.prototype.MAX_RECENT_UPDATES_TO_SELECT = 5
+      this.prototype.BATCH_SIZE = 10
+    }
+    constructor(ide, $scope, localStorage) {
+      this.labelCurrentVersion = this.labelCurrentVersion.bind(this)
+      this.deleteLabel = this.deleteLabel.bind(this)
+      this._addLabelLocally = this._addLabelLocally.bind(this)
+      this.ide = ide
+      this.$scope = $scope
+      this.localStorage = localStorage
+      this.$scope.HistoryViewModes = HistoryViewModes
+      this._localStorageViewModeProjKey = `history.userPrefs.viewMode.${
+        $scope.project_id
+      }`
+      this._localStorageShowOnlyLabelsProjKey = `history.userPrefs.showOnlyLabels.${
+        $scope.project_id
+      }`
+      this._previouslySelectedPathname = null
+      this._loadFileTreeRequestCanceller = null
+      this.hardReset()
+
+      this.$scope.toggleHistory = () => {
+        if (this.$scope.ui.view === 'history') {
+          this.hide()
+        } else {
+          this.show()
+          this._handleHistoryUIStateChange()
         }
+        this.ide.$timeout(() => {
+          this.$scope.$broadcast('history:toggle')
+        }, 0)
       }
 
-      hide() {
-        this.$scope.ui.view = 'editor'
-      }
-
-      _getViewModeUserPref() {
-        return (
-          this.localStorage(this._localStorageViewModeProjKey) ||
-          HistoryViewModes.POINT_IN_TIME
-        )
-      }
-      _getShowOnlyLabelsUserPref() {
+      this.$scope.isHistoryLoading = () => {
+        let selection = this.$scope.history.selection
         return (
-          this.localStorage(this._localStorageShowOnlyLabelsProjKey) || false
+          this.$scope.history.loadingFileTree ||
+          (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME &&
+            selection.file &&
+            selection.file.loading) ||
+          (this.$scope.history.viewMode === HistoryViewModes.COMPARE &&
+            selection.diff &&
+            selection.diff.loading)
         )
       }
+    }
 
-      _setViewModeUserPref(viewModeUserPref) {
-        if (
-          viewModeUserPref === HistoryViewModes.POINT_IN_TIME ||
-          viewModeUserPref === HistoryViewModes.COMPARE
-        ) {
-          this.localStorage(this._localStorageViewModeProjKey, viewModeUserPref)
-        }
-      }
-      _setShowOnlyLabelsUserPref(showOnlyLabelsUserPref) {
-        this.localStorage(
-          this._localStorageShowOnlyLabelsProjKey,
-          !!showOnlyLabelsUserPref
-        )
+    show() {
+      this.$scope.ui.view = 'history'
+      this.hardReset()
+      if (this.$scope.history.showOnlyLabels) {
+        this.fetchNextBatchOfUpdates()
       }
+    }
 
-      hardReset() {
-        this.$scope.history = {
-          isV2: true,
-          updates: [],
-          viewMode: this._getViewModeUserPref(),
-          nextBeforeTimestamp: null,
-          atEnd: false,
-          userHasFullFeature: undefined,
-          freeHistoryLimitHit: false,
-          selection: {
-            docs: {},
-            pathname: null,
-            range: {
-              fromV: null,
-              toV: null
-            },
-            hoveredRange: {
-              fromV: null,
-              toV: null
-            },
-            diff: null,
-            files: [],
-            file: null
-          },
-          error: null,
-          showOnlyLabels: this._getShowOnlyLabelsUserPref(),
-          labels: null,
-          loadingFileTree: true
-        }
-        let _deregisterFeatureWatcher = this.$scope.$watch(
-          'project.features.versioning',
-          hasVersioning => {
-            if (hasVersioning != null) {
-              this.$scope.history.userHasFullFeature = hasVersioning
-              if (this.$scope.user.isAdmin) {
-                this.$scope.history.userHasFullFeature = true
-              }
-              _deregisterFeatureWatcher()
-            }
-          }
-        )
+    hide() {
+      this.$scope.ui.view = 'editor'
+    }
+
+    _getViewModeUserPref() {
+      return (
+        this.localStorage(this._localStorageViewModeProjKey) ||
+        HistoryViewModes.POINT_IN_TIME
+      )
+    }
+    _getShowOnlyLabelsUserPref() {
+      return this.localStorage(this._localStorageShowOnlyLabelsProjKey) || false
+    }
+
+    _setViewModeUserPref(viewModeUserPref) {
+      if (
+        viewModeUserPref === HistoryViewModes.POINT_IN_TIME ||
+        viewModeUserPref === HistoryViewModes.COMPARE
+      ) {
+        this.localStorage(this._localStorageViewModeProjKey, viewModeUserPref)
       }
+    }
+    _setShowOnlyLabelsUserPref(showOnlyLabelsUserPref) {
+      this.localStorage(
+        this._localStorageShowOnlyLabelsProjKey,
+        !!showOnlyLabelsUserPref
+      )
+    }
 
-      softReset() {
-        this.$scope.history.viewMode = this._getViewModeUserPref()
-        this.$scope.history.selection = {
+    hardReset() {
+      this.$scope.history = {
+        isV2: true,
+        updates: [],
+        viewMode: this._getViewModeUserPref(),
+        nextBeforeTimestamp: null,
+        atEnd: false,
+        userHasFullFeature: undefined,
+        freeHistoryLimitHit: false,
+        selection: {
           docs: {},
           pathname: null,
           range: {
@@ -182,828 +143,856 @@ define([
             fromV: null,
             toV: null
           },
-          diff: null, // When history.viewMode == HistoryViewModes.COMPARE
-          files: [], // When history.viewMode == HistoryViewModes.COMPARE
+          diff: null,
+          files: [],
           file: null
-        }
-        this.$scope.history.error = null
-        this.$scope.history.showOnlyLabels = this._getShowOnlyLabelsUserPref()
-      }
-
-      toggleHistoryViewMode() {
-        if (this.$scope.history.viewMode === HistoryViewModes.COMPARE) {
-          this.softReset()
-          this.$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
-          this._setViewModeUserPref(HistoryViewModes.POINT_IN_TIME)
-        } else {
-          this.softReset()
-          this.$scope.history.viewMode = HistoryViewModes.COMPARE
-          this._setViewModeUserPref(HistoryViewModes.COMPARE)
-        }
-        this._handleHistoryUIStateChange()
-        this.ide.$timeout(() => {
-          this.$scope.$broadcast('history:toggle')
-        }, 0)
-      }
-
-      _handleHistoryUIStateChange() {
-        if (this.$scope.history.viewMode === HistoryViewModes.COMPARE) {
-          if (this.$scope.history.showOnlyLabels) {
-            this.autoSelectLabelsForComparison()
-          } else {
-            this.autoSelectRecentUpdates()
-          }
-        } else {
-          // Point-in-time mode
-          if (this.$scope.history.showOnlyLabels) {
-            this.autoSelectLabelForPointInTime()
-          } else {
-            this.autoSelectVersionForPointInTime()
+        },
+        error: null,
+        showOnlyLabels: this._getShowOnlyLabelsUserPref(),
+        labels: null,
+        loadingFileTree: true
+      }
+      let _deregisterFeatureWatcher = this.$scope.$watch(
+        'project.features.versioning',
+        hasVersioning => {
+          if (hasVersioning != null) {
+            this.$scope.history.userHasFullFeature = hasVersioning
+            if (this.$scope.user.isAdmin) {
+              this.$scope.history.userHasFullFeature = true
+            }
+            _deregisterFeatureWatcher()
           }
         }
-      }
-
-      setHoverFrom(fromV) {
-        let selection = this.$scope.history.selection
-        selection.hoveredRange.fromV = fromV
-        selection.hoveredRange.toV = selection.range.toV
-        this.$scope.history.hoveringOverListSelectors = true
-      }
+      )
+    }
 
-      setHoverTo(toV) {
-        let selection = this.$scope.history.selection
-        selection.hoveredRange.toV = toV
-        selection.hoveredRange.fromV = selection.range.fromV
-        this.$scope.history.hoveringOverListSelectors = true
-      }
+    softReset() {
+      this.$scope.history.viewMode = this._getViewModeUserPref()
+      this.$scope.history.selection = {
+        docs: {},
+        pathname: null,
+        range: {
+          fromV: null,
+          toV: null
+        },
+        hoveredRange: {
+          fromV: null,
+          toV: null
+        },
+        diff: null, // When history.viewMode == HistoryViewModes.COMPARE
+        files: [], // When history.viewMode == HistoryViewModes.COMPARE
+        file: null
+      }
+      this.$scope.history.error = null
+      this.$scope.history.showOnlyLabels = this._getShowOnlyLabelsUserPref()
+    }
 
-      resetHover() {
-        let selection = this.$scope.history.selection
-        selection.hoveredRange.toV = null
-        selection.hoveredRange.fromV = null
-        this.$scope.history.hoveringOverListSelectors = false
-      }
+    toggleHistoryViewMode() {
+      if (this.$scope.history.viewMode === HistoryViewModes.COMPARE) {
+        this.softReset()
+        this.$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME
+        this._setViewModeUserPref(HistoryViewModes.POINT_IN_TIME)
+      } else {
+        this.softReset()
+        this.$scope.history.viewMode = HistoryViewModes.COMPARE
+        this._setViewModeUserPref(HistoryViewModes.COMPARE)
+      }
+      this._handleHistoryUIStateChange()
+      this.ide.$timeout(() => {
+        this.$scope.$broadcast('history:toggle')
+      }, 0)
+    }
 
-      showAllUpdates() {
+    _handleHistoryUIStateChange() {
+      if (this.$scope.history.viewMode === HistoryViewModes.COMPARE) {
         if (this.$scope.history.showOnlyLabels) {
-          this.$scope.history.showOnlyLabels = false
-          this._setShowOnlyLabelsUserPref(false)
-          this._handleHistoryUIStateChange()
+          this.autoSelectLabelsForComparison()
+        } else {
+          this.autoSelectRecentUpdates()
         }
-      }
-
-      showOnlyLabels() {
-        if (!this.$scope.history.showOnlyLabels) {
-          this.$scope.history.showOnlyLabels = true
-          this._setShowOnlyLabelsUserPref(true)
-          this._handleHistoryUIStateChange()
+      } else {
+        // Point-in-time mode
+        if (this.$scope.history.showOnlyLabels) {
+          this.autoSelectLabelForPointInTime()
+        } else {
+          this.autoSelectVersionForPointInTime()
         }
       }
+    }
 
-      restoreFile(version, pathname) {
-        const url = `/project/${this.$scope.project_id}/restore_file`
+    setHoverFrom(fromV) {
+      let selection = this.$scope.history.selection
+      selection.hoveredRange.fromV = fromV
+      selection.hoveredRange.toV = selection.range.toV
+      this.$scope.history.hoveringOverListSelectors = true
+    }
 
-        return this.ide.$http.post(url, {
-          version,
-          pathname,
-          _csrf: window.csrfToken
-        })
-      }
+    setHoverTo(toV) {
+      let selection = this.$scope.history.selection
+      selection.hoveredRange.toV = toV
+      selection.hoveredRange.fromV = selection.range.fromV
+      this.$scope.history.hoveringOverListSelectors = true
+    }
+
+    resetHover() {
+      let selection = this.$scope.history.selection
+      selection.hoveredRange.toV = null
+      selection.hoveredRange.fromV = null
+      this.$scope.history.hoveringOverListSelectors = false
+    }
 
-      loadFileTreeForVersion(version) {
-        return this._loadFileTree(version, version)
+    showAllUpdates() {
+      if (this.$scope.history.showOnlyLabels) {
+        this.$scope.history.showOnlyLabels = false
+        this._setShowOnlyLabelsUserPref(false)
+        this._handleHistoryUIStateChange()
       }
+    }
 
-      loadFileTreeDiff(toV, fromV) {
-        return this._loadFileTree(toV, fromV)
+    showOnlyLabels() {
+      if (!this.$scope.history.showOnlyLabels) {
+        this.$scope.history.showOnlyLabels = true
+        this._setShowOnlyLabelsUserPref(true)
+        this._handleHistoryUIStateChange()
       }
+    }
 
-      _loadFileTree(toV, fromV) {
-        let url = `/project/${this.$scope.project_id}/filetree/diff`
-        let selection = this.$scope.history.selection
-        const query = [`from=${fromV}`, `to=${toV}`]
-        url += `?${query.join('&')}`
+    restoreFile(version, pathname) {
+      const url = `/project/${this.$scope.project_id}/restore_file`
 
-        this.$scope.$applyAsync(
-          () => (this.$scope.history.loadingFileTree = true)
-        )
+      return this.ide.$http.post(url, {
+        version,
+        pathname,
+        _csrf: window.csrfToken
+      })
+    }
 
-        selection.file = null
-        selection.pathname = null
+    loadFileTreeForVersion(version) {
+      return this._loadFileTree(version, version)
+    }
 
-        // If `this._loadFileTreeRequestCanceller` is not null, then we have a request inflight
-        if (this._loadFileTreeRequestCanceller != null) {
-          // Resolving it will cancel the inflight request (or, rather, ignore its result)
-          this._loadFileTreeRequestCanceller.resolve()
-        }
-        this._loadFileTreeRequestCanceller = this.ide.$q.defer()
-
-        return this.ide.$http
-          .get(url, { timeout: this._loadFileTreeRequestCanceller.promise })
-          .then(response => {
-            this.$scope.history.selection.files = response.data.diff
-            for (let file of this.$scope.history.selection.files) {
-              if (file.newPathname != null) {
-                file.oldPathname = file.pathname
-                file.pathname = file.newPathname
-                delete file.newPathname
-              }
+    loadFileTreeDiff(toV, fromV) {
+      return this._loadFileTree(toV, fromV)
+    }
+
+    _loadFileTree(toV, fromV) {
+      let url = `/project/${this.$scope.project_id}/filetree/diff`
+      let selection = this.$scope.history.selection
+      const query = [`from=${fromV}`, `to=${toV}`]
+      url += `?${query.join('&')}`
+
+      this.$scope.$applyAsync(
+        () => (this.$scope.history.loadingFileTree = true)
+      )
+
+      selection.file = null
+      selection.pathname = null
+
+      // If `this._loadFileTreeRequestCanceller` is not null, then we have a request inflight
+      if (this._loadFileTreeRequestCanceller != null) {
+        // Resolving it will cancel the inflight request (or, rather, ignore its result)
+        this._loadFileTreeRequestCanceller.resolve()
+      }
+      this._loadFileTreeRequestCanceller = this.ide.$q.defer()
+
+      return this.ide.$http
+        .get(url, { timeout: this._loadFileTreeRequestCanceller.promise })
+        .then(response => {
+          this.$scope.history.selection.files = response.data.diff
+          for (let file of this.$scope.history.selection.files) {
+            if (file.newPathname != null) {
+              file.oldPathname = file.pathname
+              file.pathname = file.newPathname
+              delete file.newPathname
             }
+          }
+          this._loadFileTreeRequestCanceller = null
+          this.$scope.history.loadingFileTree = false
+          this.autoSelectFile()
+        })
+        .catch(err => {
+          if (err.status !== -1) {
             this._loadFileTreeRequestCanceller = null
-            this.$scope.history.loadingFileTree = false
-            this.autoSelectFile()
-          })
-          .catch(err => {
-            if (err.status !== -1) {
-              this._loadFileTreeRequestCanceller = null
-            } else {
-              this.$scope.history.loadingFileTree = false
-            }
-          })
-      }
-
-      selectFile(file) {
-        if (file != null && file.pathname != null) {
-          this.$scope.history.selection.pathname = this._previouslySelectedPathname =
-            file.pathname
-          this.$scope.history.selection.file = file
-          if (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME) {
-            this.loadFileAtPointInTime()
           } else {
-            this.reloadDiff()
+            this.$scope.history.loadingFileTree = false
           }
+        })
+    }
+
+    selectFile(file) {
+      if (file != null && file.pathname != null) {
+        this.$scope.history.selection.pathname = this._previouslySelectedPathname =
+          file.pathname
+        this.$scope.history.selection.file = file
+        if (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME) {
+          this.loadFileAtPointInTime()
+        } else {
+          this.reloadDiff()
         }
       }
+    }
 
-      autoSelectFile() {
-        let selectedPathname = null
-        let files = this.$scope.history.selection.files
-        let fileToSelect = null
-        let previouslySelectedFile = null
-        let previouslySelectedFileHasOp = false
-        let filesWithOps = this._getFilesWithOps()
-        const orderedOpTypes = ['edited', 'added', 'renamed', 'removed']
-
-        if (this._previouslySelectedPathname != null) {
-          previouslySelectedFile = _.find(files, {
-            pathname: this._previouslySelectedPathname
-          })
-          previouslySelectedFileHasOp = _.some(filesWithOps, {
-            pathname: this._previouslySelectedPathname
+    autoSelectFile() {
+      let selectedPathname = null
+      let files = this.$scope.history.selection.files
+      let fileToSelect = null
+      let previouslySelectedFile = null
+      let previouslySelectedFileHasOp = false
+      let filesWithOps = this._getFilesWithOps()
+      const orderedOpTypes = ['edited', 'added', 'renamed', 'removed']
+
+      if (this._previouslySelectedPathname != null) {
+        previouslySelectedFile = _.find(files, {
+          pathname: this._previouslySelectedPathname
+        })
+        previouslySelectedFileHasOp = _.some(filesWithOps, {
+          pathname: this._previouslySelectedPathname
+        })
+      }
+      if (previouslySelectedFile != null && previouslySelectedFileHasOp) {
+        fileToSelect = previouslySelectedFile
+      } else {
+        for (let opType of orderedOpTypes) {
+          let fileWithMatchingOpType = _.find(filesWithOps, {
+            operation: opType
           })
-        }
-        if (previouslySelectedFile != null && previouslySelectedFileHasOp) {
-          fileToSelect = previouslySelectedFile
-        } else {
-          for (let opType of orderedOpTypes) {
-            let fileWithMatchingOpType = _.find(filesWithOps, {
-              operation: opType
+          if (fileWithMatchingOpType != null) {
+            fileToSelect = _.find(files, {
+              pathname: fileWithMatchingOpType.pathname
             })
-            if (fileWithMatchingOpType != null) {
-              fileToSelect = _.find(files, {
-                pathname: fileWithMatchingOpType.pathname
-              })
-              break
-            }
+            break
           }
-          if (fileToSelect == null) {
-            if (previouslySelectedFile != null) {
-              fileToSelect = previouslySelectedFile
+        }
+        if (fileToSelect == null) {
+          if (previouslySelectedFile != null) {
+            fileToSelect = previouslySelectedFile
+          } else {
+            let mainFile = _.find(files, function(file) {
+              return /main\.tex$/.test(file.pathname)
+            })
+            if (mainFile != null) {
+              fileToSelect = mainFile
             } else {
-              let mainFile = _.find(files, function(file) {
-                return /main\.tex$/.test(file.pathname)
+              let anyTeXFile = _.find(files, function(file) {
+                return /\.tex$/.test(file.pathname)
               })
-              if (mainFile != null) {
-                fileToSelect = mainFile
+              if (anyTeXFile != null) {
+                fileToSelect = anyTeXFile
               } else {
-                let anyTeXFile = _.find(files, function(file) {
-                  return /\.tex$/.test(file.pathname)
-                })
-                if (anyTeXFile != null) {
-                  fileToSelect = anyTeXFile
-                } else {
-                  fileToSelect = files[0]
-                }
+                fileToSelect = files[0]
               }
             }
           }
         }
-
-        this.selectFile(fileToSelect)
       }
 
-      _getFilesWithOps() {
-        let filesWithOps
-        if (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME) {
-          let currentUpdate = this.getUpdateForVersion(
-            this.$scope.history.selection.range.toV
-          )
-          filesWithOps = []
-          if (currentUpdate != null) {
-            for (let pathname of currentUpdate.pathnames) {
-              filesWithOps.push({
-                pathname: pathname,
-                operation: 'edited'
-              })
-            }
-            for (let op of currentUpdate.project_ops) {
-              let fileWithOp
-              if (op.add != null) {
-                fileWithOp = {
-                  pathname: op.add.pathname,
-                  operation: 'added'
-                }
-              } else if (op.remove != null) {
-                fileWithOp = {
-                  pathname: op.remove.pathname,
-                  operation: 'removed'
-                }
-              } else if (op.rename != null) {
-                fileWithOp = {
-                  pathname: op.rename.newPathname,
-                  operation: 'renamed'
-                }
+      this.selectFile(fileToSelect)
+    }
+
+    _getFilesWithOps() {
+      let filesWithOps
+      if (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME) {
+        let currentUpdate = this.getUpdateForVersion(
+          this.$scope.history.selection.range.toV
+        )
+        filesWithOps = []
+        if (currentUpdate != null) {
+          for (let pathname of currentUpdate.pathnames) {
+            filesWithOps.push({
+              pathname: pathname,
+              operation: 'edited'
+            })
+          }
+          for (let op of currentUpdate.project_ops) {
+            let fileWithOp
+            if (op.add != null) {
+              fileWithOp = {
+                pathname: op.add.pathname,
+                operation: 'added'
+              }
+            } else if (op.remove != null) {
+              fileWithOp = {
+                pathname: op.remove.pathname,
+                operation: 'removed'
               }
-              if (fileWithOp != null) {
-                filesWithOps.push(fileWithOp)
+            } else if (op.rename != null) {
+              fileWithOp = {
+                pathname: op.rename.newPathname,
+                operation: 'renamed'
               }
             }
+            if (fileWithOp != null) {
+              filesWithOps.push(fileWithOp)
+            }
           }
-        } else {
-          filesWithOps = _.reduce(
-            this.$scope.history.selection.files,
-            (curFilesWithOps, file) => {
-              if (file.operation) {
-                curFilesWithOps.push({
-                  pathname: file.pathname,
-                  operation: file.operation
-                })
-              }
-              return curFilesWithOps
-            },
-            []
-          )
         }
-        return filesWithOps
+      } else {
+        filesWithOps = _.reduce(
+          this.$scope.history.selection.files,
+          (curFilesWithOps, file) => {
+            if (file.operation) {
+              curFilesWithOps.push({
+                pathname: file.pathname,
+                operation: file.operation
+              })
+            }
+            return curFilesWithOps
+          },
+          []
+        )
       }
+      return filesWithOps
+    }
 
-      autoSelectRecentUpdates() {
-        if (this.$scope.history.updates.length === 0) {
-          return
-        }
-
-        let toV = this.$scope.history.updates[0].toV
-        let fromV = null
-
-        let indexOfLastUpdateNotByMe = 0
-        for (let i = 0; i < this.$scope.history.updates.length; i++) {
-          const update = this.$scope.history.updates[i]
-          if (
-            this._updateContainsUserId(update, this.$scope.user.id) ||
-            i > this.MAX_RECENT_UPDATES_TO_SELECT
-          ) {
-            break
-          }
-          indexOfLastUpdateNotByMe = i
-        }
-
-        fromV = this.$scope.history.updates[indexOfLastUpdateNotByMe].fromV
-        this.selectVersionsForCompare(toV, fromV)
+    autoSelectRecentUpdates() {
+      if (this.$scope.history.updates.length === 0) {
+        return
       }
 
-      autoSelectVersionForPointInTime() {
-        if (this.$scope.history.updates.length === 0) {
-          return
-        }
-        let versionToSelect = this.$scope.history.updates[0].toV
-        let range = this.$scope.history.selection.range
-        if (
-          range.toV != null &&
-          range.fromV != null &&
-          range.toV === range.fromV
-        ) {
-          versionToSelect = range.toV
-        }
-        this.selectVersionForPointInTime(versionToSelect)
-      }
+      let toV = this.$scope.history.updates[0].toV
+      let fromV = null
 
-      autoSelectLastLabel() {
+      let indexOfLastUpdateNotByMe = 0
+      for (let i = 0; i < this.$scope.history.updates.length; i++) {
+        const update = this.$scope.history.updates[i]
         if (
-          this.$scope.history.labels == null ||
-          this.$scope.history.labels.length === 0
+          this._updateContainsUserId(update, this.$scope.user.id) ||
+          i > this.MAX_RECENT_UPDATES_TO_SELECT
         ) {
-          return
+          break
         }
-        return this.selectLabelForPointInTime(this.$scope.history.labels[0])
+        indexOfLastUpdateNotByMe = i
       }
 
-      expandSelectionToVersion(version) {
-        if (version > this.$scope.history.selection.range.toV) {
-          this.$scope.history.selection.range.toV = version
-        } else if (version < this.$scope.history.selection.range.fromV) {
-          this.$scope.history.selection.range.fromV = version
-        }
+      fromV = this.$scope.history.updates[indexOfLastUpdateNotByMe].fromV
+      this.selectVersionsForCompare(toV, fromV)
+    }
+
+    autoSelectVersionForPointInTime() {
+      if (this.$scope.history.updates.length === 0) {
+        return
       }
+      let versionToSelect = this.$scope.history.updates[0].toV
+      let range = this.$scope.history.selection.range
+      if (
+        range.toV != null &&
+        range.fromV != null &&
+        range.toV === range.fromV
+      ) {
+        versionToSelect = range.toV
+      }
+      this.selectVersionForPointInTime(versionToSelect)
+    }
 
-      selectVersionForPointInTime(version) {
-        let selection = this.$scope.history.selection
-        if (
-          selection.range.toV !== version &&
-          selection.range.fromV !== version
-        ) {
-          selection.range.toV = version
-          selection.range.fromV = version
-          this.loadFileTreeForVersion(version)
-        }
+    autoSelectLastLabel() {
+      if (
+        this.$scope.history.labels == null ||
+        this.$scope.history.labels.length === 0
+      ) {
+        return
       }
+      return this.selectLabelForPointInTime(this.$scope.history.labels[0])
+    }
 
-      selectVersionsForCompare(toV, fromV) {
-        let range = this.$scope.history.selection.range
-        if (range.toV !== toV || range.fromV !== fromV) {
-          range.toV = toV
-          range.fromV = fromV
-          this.loadFileTreeDiff(toV, fromV)
-        }
+    expandSelectionToVersion(version) {
+      if (version > this.$scope.history.selection.range.toV) {
+        this.$scope.history.selection.range.toV = version
+      } else if (version < this.$scope.history.selection.range.fromV) {
+        this.$scope.history.selection.range.fromV = version
       }
+    }
 
-      autoSelectLabelForPointInTime() {
-        const selectedUpdate = this.getUpdateForVersion(
-          this.$scope.history.selection.range.toV
-        )
-        let nSelectedLabels = 0
+    selectVersionForPointInTime(version) {
+      let selection = this.$scope.history.selection
+      if (
+        selection.range.toV !== version &&
+        selection.range.fromV !== version
+      ) {
+        selection.range.toV = version
+        selection.range.fromV = version
+        this.loadFileTreeForVersion(version)
+      }
+    }
 
-        if (selectedUpdate != null && selectedUpdate.labels != null) {
-          nSelectedLabels = selectedUpdate.labels.length
-        }
+    selectVersionsForCompare(toV, fromV) {
+      let range = this.$scope.history.selection.range
+      if (range.toV !== toV || range.fromV !== fromV) {
+        range.toV = toV
+        range.fromV = fromV
+        this.loadFileTreeDiff(toV, fromV)
+      }
+    }
 
-        // If the currently selected update has no labels, select the last one (version-wise)
-        if (nSelectedLabels === 0) {
-          this.autoSelectLastLabel()
-          // If the update has one label, select it
-        } else if (nSelectedLabels === 1) {
-          this.selectLabelForPointInTime(selectedUpdate.labels[0])
-          // If there are multiple labels for the update, select the latest
-        } else if (nSelectedLabels > 1) {
-          const sortedLabels = this.ide.$filter('orderBy')(
-            selectedUpdate.labels,
-            '-created_at'
-          )
-          const lastLabelFromUpdate = sortedLabels[0]
-          this.selectLabelForPointInTime(lastLabelFromUpdate)
-        }
+    autoSelectLabelForPointInTime() {
+      const selectedUpdate = this.getUpdateForVersion(
+        this.$scope.history.selection.range.toV
+      )
+      let nSelectedLabels = 0
+
+      if (selectedUpdate != null && selectedUpdate.labels != null) {
+        nSelectedLabels = selectedUpdate.labels.length
+      }
+
+      // If the currently selected update has no labels, select the last one (version-wise)
+      if (nSelectedLabels === 0) {
+        this.autoSelectLastLabel()
+        // If the update has one label, select it
+      } else if (nSelectedLabels === 1) {
+        this.selectLabelForPointInTime(selectedUpdate.labels[0])
+        // If there are multiple labels for the update, select the latest
+      } else if (nSelectedLabels > 1) {
+        const sortedLabels = this.ide.$filter('orderBy')(
+          selectedUpdate.labels,
+          '-created_at'
+        )
+        const lastLabelFromUpdate = sortedLabels[0]
+        this.selectLabelForPointInTime(lastLabelFromUpdate)
       }
+    }
 
-      selectLabelForPointInTime(labelToSelect) {
-        let updateToSelect = null
+    selectLabelForPointInTime(labelToSelect) {
+      let updateToSelect = null
 
-        if (this._isLabelSelected(labelToSelect)) {
-          // Label already selected
-          return
-        }
+      if (this._isLabelSelected(labelToSelect)) {
+        // Label already selected
+        return
+      }
 
-        for (let update of Array.from(this.$scope.history.updates)) {
-          if (update.toV === labelToSelect.version) {
-            updateToSelect = update
-            break
-          }
+      for (let update of Array.from(this.$scope.history.updates)) {
+        if (update.toV === labelToSelect.version) {
+          updateToSelect = update
+          break
         }
+      }
 
-        if (updateToSelect != null) {
-          this.selectVersionForPointInTime(updateToSelect.toV)
-        } else {
-          let selection = this.$scope.history.selection
-          selection.range.toV = labelToSelect.version
-          selection.range.fromV = labelToSelect.version
-          this.loadFileTreeForVersion(labelToSelect.version)
-        }
+      if (updateToSelect != null) {
+        this.selectVersionForPointInTime(updateToSelect.toV)
+      } else {
+        let selection = this.$scope.history.selection
+        selection.range.toV = labelToSelect.version
+        selection.range.fromV = labelToSelect.version
+        this.loadFileTreeForVersion(labelToSelect.version)
       }
+    }
 
-      getUpdateForVersion(version) {
-        for (let update of this.$scope.history.updates) {
-          if (update.toV === version) {
-            return update
-          }
+    getUpdateForVersion(version) {
+      for (let update of this.$scope.history.updates) {
+        if (update.toV === version) {
+          return update
         }
       }
+    }
 
-      autoSelectLabelsForComparison() {
-        let labels = this.$scope.history.labels
-        let nLabels = 0
-        if (Array.isArray(labels)) {
-          nLabels = labels.length
-        }
-        if (nLabels === 0) {
-          // TODO better handling
-        } else if (nLabels === 1) {
-          this.selectVersionsForCompare(labels[0].version, labels[0].version)
-        } else if (nLabels > 1) {
-          this.selectVersionsForCompare(labels[0].version, labels[1].version)
-        }
+    autoSelectLabelsForComparison() {
+      let labels = this.$scope.history.labels
+      let nLabels = 0
+      if (Array.isArray(labels)) {
+        nLabels = labels.length
+      }
+      if (nLabels === 0) {
+        // TODO better handling
+      } else if (nLabels === 1) {
+        this.selectVersionsForCompare(labels[0].version, labels[0].version)
+      } else if (nLabels > 1) {
+        this.selectVersionsForCompare(labels[0].version, labels[1].version)
       }
+    }
 
-      fetchNextBatchOfUpdates() {
-        if (this.$scope.history.atEnd) {
-          return
-        }
+    fetchNextBatchOfUpdates() {
+      if (this.$scope.history.atEnd) {
+        return
+      }
 
-        let updatesURL = `/project/${this.ide.project_id}/updates?min_count=${
-          this.BATCH_SIZE
-        }`
-        if (this.$scope.history.nextBeforeTimestamp != null) {
-          updatesURL += `&before=${this.$scope.history.nextBeforeTimestamp}`
-        }
-        const labelsURL = `/project/${this.ide.project_id}/labels`
+      let updatesURL = `/project/${this.ide.project_id}/updates?min_count=${
+        this.BATCH_SIZE
+      }`
+      if (this.$scope.history.nextBeforeTimestamp != null) {
+        updatesURL += `&before=${this.$scope.history.nextBeforeTimestamp}`
+      }
+      const labelsURL = `/project/${this.ide.project_id}/labels`
 
-        const requests = { updates: this.ide.$http.get(updatesURL) }
+      const requests = { updates: this.ide.$http.get(updatesURL) }
 
-        if (this.$scope.history.labels == null) {
-          requests.labels = this.ide.$http.get(labelsURL)
-        }
+      if (this.$scope.history.labels == null) {
+        requests.labels = this.ide.$http.get(labelsURL)
+      }
 
-        return this.ide.$q
-          .all(requests)
-          .then(response => {
-            const updatesData = response.updates.data
-            let lastUpdateToV = null
+      return this.ide.$q
+        .all(requests)
+        .then(response => {
+          const updatesData = response.updates.data
+          let lastUpdateToV = null
 
-            if (updatesData.updates.length > 0) {
-              lastUpdateToV = updatesData.updates[0].toV
-            }
-            if (response.labels != null) {
-              this.$scope.history.labels = this._loadLabels(
-                response.labels.data,
-                lastUpdateToV
-              )
-            }
-            this._loadUpdates(updatesData.updates)
-            this.$scope.history.nextBeforeTimestamp =
-              updatesData.nextBeforeTimestamp
-            if (
-              updatesData.nextBeforeTimestamp == null ||
-              this.$scope.history.freeHistoryLimitHit ||
-              this.$scope.history.updates.length === 0
-            ) {
-              this.$scope.history.atEnd = true
-            }
-            if (this.$scope.history.updates.length === 0) {
-              this.$scope.history.loadingFileTree = false
-            }
-          })
-          .catch(error => {
-            const { status, statusText } = error
-            this.$scope.history.error = { status, statusText }
+          if (updatesData.updates.length > 0) {
+            lastUpdateToV = updatesData.updates[0].toV
+          }
+          if (response.labels != null) {
+            this.$scope.history.labels = this._loadLabels(
+              response.labels.data,
+              lastUpdateToV
+            )
+          }
+          this._loadUpdates(updatesData.updates)
+          this.$scope.history.nextBeforeTimestamp =
+            updatesData.nextBeforeTimestamp
+          if (
+            updatesData.nextBeforeTimestamp == null ||
+            this.$scope.history.freeHistoryLimitHit ||
+            this.$scope.history.updates.length === 0
+          ) {
             this.$scope.history.atEnd = true
+          }
+          if (this.$scope.history.updates.length === 0) {
             this.$scope.history.loadingFileTree = false
-          })
-      }
-
-      _loadLabels(labels, lastUpdateToV) {
-        let sortedLabels = this._sortLabelsByVersionAndDate(labels)
-        let nLabels = sortedLabels.length
-        let hasPseudoCurrentStateLabel = false
-        let needsPseudoCurrentStateLabel = false
-        if (lastUpdateToV) {
-          hasPseudoCurrentStateLabel =
-            nLabels > 0 ? sortedLabels[0].isPseudoCurrentStateLabel : false
-          if (hasPseudoCurrentStateLabel) {
-            needsPseudoCurrentStateLabel =
-              nLabels > 1 ? sortedLabels[1].version !== lastUpdateToV : false
-          } else {
-            needsPseudoCurrentStateLabel =
-              nLabels > 0 ? sortedLabels[0].version !== lastUpdateToV : true
           }
-          if (needsPseudoCurrentStateLabel && !hasPseudoCurrentStateLabel) {
-            let pseudoCurrentStateLabel = {
-              id: '1',
-              isPseudoCurrentStateLabel: true,
-              version: lastUpdateToV,
-              created_at: new Date().toISOString()
-            }
-            sortedLabels.unshift(pseudoCurrentStateLabel)
-          } else if (
-            !needsPseudoCurrentStateLabel &&
-            hasPseudoCurrentStateLabel
-          ) {
-            sortedLabels.shift()
+        })
+        .catch(error => {
+          const { status, statusText } = error
+          this.$scope.history.error = { status, statusText }
+          this.$scope.history.atEnd = true
+          this.$scope.history.loadingFileTree = false
+        })
+    }
+
+    _loadLabels(labels, lastUpdateToV) {
+      let sortedLabels = this._sortLabelsByVersionAndDate(labels)
+      let nLabels = sortedLabels.length
+      let hasPseudoCurrentStateLabel = false
+      let needsPseudoCurrentStateLabel = false
+      if (lastUpdateToV) {
+        hasPseudoCurrentStateLabel =
+          nLabels > 0 ? sortedLabels[0].isPseudoCurrentStateLabel : false
+        if (hasPseudoCurrentStateLabel) {
+          needsPseudoCurrentStateLabel =
+            nLabels > 1 ? sortedLabels[1].version !== lastUpdateToV : false
+        } else {
+          needsPseudoCurrentStateLabel =
+            nLabels > 0 ? sortedLabels[0].version !== lastUpdateToV : true
+        }
+        if (needsPseudoCurrentStateLabel && !hasPseudoCurrentStateLabel) {
+          let pseudoCurrentStateLabel = {
+            id: '1',
+            isPseudoCurrentStateLabel: true,
+            version: lastUpdateToV,
+            created_at: new Date().toISOString()
           }
+          sortedLabels.unshift(pseudoCurrentStateLabel)
+        } else if (
+          !needsPseudoCurrentStateLabel &&
+          hasPseudoCurrentStateLabel
+        ) {
+          sortedLabels.shift()
         }
-        return sortedLabels
       }
+      return sortedLabels
+    }
 
-      _sortLabelsByVersionAndDate(labels) {
-        return this.ide.$filter('orderBy')(labels, [
-          'isPseudoCurrentStateLabel',
-          '-version',
-          '-created_at'
-        ])
-      }
+    _sortLabelsByVersionAndDate(labels) {
+      return this.ide.$filter('orderBy')(labels, [
+        'isPseudoCurrentStateLabel',
+        '-version',
+        '-created_at'
+      ])
+    }
 
-      loadFileAtPointInTime() {
-        const toV = this.$scope.history.selection.range.toV
-        const { pathname } = this.$scope.history.selection
-        if (toV == null) {
-          return
-        }
-        let url = `/project/${this.$scope.project_id}/diff`
-        const query = [
-          `pathname=${encodeURIComponent(pathname)}`,
-          `from=${toV}`,
-          `to=${toV}`
-        ]
-        url += `?${query.join('&')}`
-        this.$scope.history.selection.file.loading = true
-        return this.ide.$http
-          .get(url)
-          .then(response => {
-            const { text, binary } = this._parseDiff(response.data.diff)
-            this.$scope.history.selection.file.binary = binary
-            this.$scope.history.selection.file.text = text
-            this.$scope.history.selection.file.loading = false
-          })
-          .catch(function() {})
-      }
+    loadFileAtPointInTime() {
+      const toV = this.$scope.history.selection.range.toV
+      const { pathname } = this.$scope.history.selection
+      if (toV == null) {
+        return
+      }
+      let url = `/project/${this.$scope.project_id}/diff`
+      const query = [
+        `pathname=${encodeURIComponent(pathname)}`,
+        `from=${toV}`,
+        `to=${toV}`
+      ]
+      url += `?${query.join('&')}`
+      this.$scope.history.selection.file.loading = true
+      return this.ide.$http
+        .get(url)
+        .then(response => {
+          const { text, binary } = this._parseDiff(response.data.diff)
+          this.$scope.history.selection.file.binary = binary
+          this.$scope.history.selection.file.text = text
+          this.$scope.history.selection.file.loading = false
+        })
+        .catch(function() {})
+    }
 
-      reloadDiff() {
-        let { diff } = this.$scope.history.selection
-        const { range, pathname } = this.$scope.history.selection
-        const { fromV, toV } = range
+    reloadDiff() {
+      let { diff } = this.$scope.history.selection
+      const { range, pathname } = this.$scope.history.selection
+      const { fromV, toV } = range
+
+      if (pathname == null) {
+        this.$scope.history.selection.diff = null
+        return
+      }
+
+      if (
+        diff != null &&
+        diff.pathname === pathname &&
+        diff.fromV === fromV &&
+        diff.toV === toV
+      ) {
+        return
+      }
+
+      this.$scope.history.selection.diff = diff = {
+        fromV,
+        toV,
+        pathname,
+        error: false
+      }
+
+      diff.loading = true
+      let url = `/project/${this.$scope.project_id}/diff`
+      const query = [`pathname=${encodeURIComponent(pathname)}`]
+      if (diff.fromV != null && diff.toV != null) {
+        query.push(`from=${diff.fromV}`, `to=${diff.toV}`)
+      }
+      url += `?${query.join('&')}`
+      return this.ide.$http
+        .get(url)
+        .then(response => {
+          const { data } = response
+          diff.loading = false
+          const { text, highlights, binary } = this._parseDiff(data.diff)
+          diff.binary = binary
+          diff.text = text
+          diff.highlights = highlights
+        })
+        .catch(function() {
+          diff.loading = false
+          diff.error = true
+        })
+    }
 
-        if (pathname == null) {
-          this.$scope.history.selection.diff = null
-          return
-        }
+    labelCurrentVersion(labelComment) {
+      return this._labelVersion(
+        labelComment,
+        this.$scope.history.selection.range.toV
+      )
+    }
 
-        if (
-          diff != null &&
-          diff.pathname === pathname &&
-          diff.fromV === fromV &&
-          diff.toV === toV
-        ) {
-          return
-        }
+    deleteLabel(label) {
+      const url = `/project/${this.$scope.project_id}/labels/${label.id}`
 
-        this.$scope.history.selection.diff = diff = {
-          fromV,
-          toV,
-          pathname,
-          error: false
-        }
+      return this.ide
+        .$http({
+          url,
+          method: 'DELETE',
+          headers: {
+            'X-CSRF-Token': window.csrfToken
+          }
+        })
+        .then(response => {
+          return this._deleteLabelLocally(label)
+        })
+    }
 
-        diff.loading = true
-        let url = `/project/${this.$scope.project_id}/diff`
-        const query = [`pathname=${encodeURIComponent(pathname)}`]
-        if (diff.fromV != null && diff.toV != null) {
-          query.push(`from=${diff.fromV}`, `to=${diff.toV}`)
+    _deleteLabelLocally(labelToDelete) {
+      for (let i = 0; i < this.$scope.history.updates.length; i++) {
+        const update = this.$scope.history.updates[i]
+        if (update.toV === labelToDelete.version) {
+          update.labels = _.filter(
+            update.labels,
+            label => label.id !== labelToDelete.id
+          )
+          break
         }
-        url += `?${query.join('&')}`
-        return this.ide.$http
-          .get(url)
-          .then(response => {
-            const { data } = response
-            diff.loading = false
-            const { text, highlights, binary } = this._parseDiff(data.diff)
-            diff.binary = binary
-            diff.text = text
-            diff.highlights = highlights
-          })
-          .catch(function() {
-            diff.loading = false
-            diff.error = true
-          })
-      }
-
-      labelCurrentVersion(labelComment) {
-        return this._labelVersion(
-          labelComment,
-          this.$scope.history.selection.range.toV
-        )
-      }
-
-      deleteLabel(label) {
-        const url = `/project/${this.$scope.project_id}/labels/${label.id}`
-
-        return this.ide
-          .$http({
-            url,
-            method: 'DELETE',
-            headers: {
-              'X-CSRF-Token': window.csrfToken
-            }
-          })
-          .then(response => {
-            return this._deleteLabelLocally(label)
-          })
       }
+      this.$scope.history.labels = this._loadLabels(
+        _.filter(
+          this.$scope.history.labels,
+          label => label.id !== labelToDelete.id
+        ),
+        this.$scope.history.updates[0].toV
+      )
+      this._handleHistoryUIStateChange()
+    }
 
-      _deleteLabelLocally(labelToDelete) {
-        for (let i = 0; i < this.$scope.history.updates.length; i++) {
-          const update = this.$scope.history.updates[i]
-          if (update.toV === labelToDelete.version) {
-            update.labels = _.filter(
-              update.labels,
-              label => label.id !== labelToDelete.id
-            )
-            break
-          }
-        }
-        this.$scope.history.labels = this._loadLabels(
-          _.filter(
-            this.$scope.history.labels,
-            label => label.id !== labelToDelete.id
-          ),
-          this.$scope.history.updates[0].toV
+    _isLabelSelected(label) {
+      if (label) {
+        return (
+          label.version <= this.$scope.history.selection.range.toV &&
+          label.version >= this.$scope.history.selection.range.fromV
         )
-        this._handleHistoryUIStateChange()
+      } else {
+        return false
       }
+    }
 
-      _isLabelSelected(label) {
-        if (label) {
-          return (
-            label.version <= this.$scope.history.selection.range.toV &&
-            label.version >= this.$scope.history.selection.range.fromV
-          )
+    _parseDiff(diff) {
+      if (diff.binary) {
+        return { binary: true }
+      }
+      let row = 0
+      let column = 0
+      const highlights = []
+      let text = ''
+      const iterable = diff || []
+      for (let i = 0; i < iterable.length; i++) {
+        var endColumn, endRow
+        const entry = iterable[i]
+        let content = entry.u || entry.i || entry.d
+        if (!content) {
+          content = ''
+        }
+        text += content
+        const lines = content.split('\n')
+        const startRow = row
+        const startColumn = column
+        if (lines.length > 1) {
+          endRow = startRow + lines.length - 1
+          endColumn = lines[lines.length - 1].length
         } else {
-          return false
+          endRow = startRow
+          endColumn = startColumn + lines[0].length
         }
-      }
+        row = endRow
+        column = endColumn
 
-      _parseDiff(diff) {
-        if (diff.binary) {
-          return { binary: true }
-        }
-        let row = 0
-        let column = 0
-        const highlights = []
-        let text = ''
-        const iterable = diff || []
-        for (let i = 0; i < iterable.length; i++) {
-          var endColumn, endRow
-          const entry = iterable[i]
-          let content = entry.u || entry.i || entry.d
-          if (!content) {
-            content = ''
-          }
-          text += content
-          const lines = content.split('\n')
-          const startRow = row
-          const startColumn = column
-          if (lines.length > 1) {
-            endRow = startRow + lines.length - 1
-            endColumn = lines[lines.length - 1].length
-          } else {
-            endRow = startRow
-            endColumn = startColumn + lines[0].length
-          }
-          row = endRow
-          column = endColumn
-
-          const range = {
-            start: {
-              row: startRow,
-              column: startColumn
-            },
-            end: {
-              row: endRow,
-              column: endColumn
-            }
+        const range = {
+          start: {
+            row: startRow,
+            column: startColumn
+          },
+          end: {
+            row: endRow,
+            column: endColumn
           }
+        }
 
-          if (entry.i != null || entry.d != null) {
-            const user =
-              entry.meta.users != null ? entry.meta.users[0] : undefined
-            const name = displayNameForUser(user)
-            const date = moment(entry.meta.end_ts).format('Do MMM YYYY, h:mm a')
-            if (entry.i != null) {
-              highlights.push({
-                label: `Added by ${name} on ${date}`,
-                highlight: range,
-                hue: ColorManager.getHueForUserId(
-                  user != null ? user.id : undefined
-                )
-              })
-            } else if (entry.d != null) {
-              highlights.push({
-                label: `Deleted by ${name} on ${date}`,
-                strikeThrough: range,
-                hue: ColorManager.getHueForUserId(
-                  user != null ? user.id : undefined
-                )
-              })
-            }
+        if (entry.i != null || entry.d != null) {
+          const user =
+            entry.meta.users != null ? entry.meta.users[0] : undefined
+          const name = displayNameForUser(user)
+          const date = moment(entry.meta.end_ts).format('Do MMM YYYY, h:mm a')
+          if (entry.i != null) {
+            highlights.push({
+              label: `Added by ${name} on ${date}`,
+              highlight: range,
+              hue: ColorManager.getHueForUserId(
+                user != null ? user.id : undefined
+              )
+            })
+          } else if (entry.d != null) {
+            highlights.push({
+              label: `Deleted by ${name} on ${date}`,
+              strikeThrough: range,
+              hue: ColorManager.getHueForUserId(
+                user != null ? user.id : undefined
+              )
+            })
           }
         }
-
-        return { text, highlights }
       }
 
-      _loadUpdates(updates) {
-        if (updates == null) {
-          updates = []
-        }
-        let previousUpdate = this.$scope.history.updates[
-          this.$scope.history.updates.length - 1
-        ]
-        const dateTimeNow = new Date()
-        const timestamp24hoursAgo = dateTimeNow.setDate(
-          dateTimeNow.getDate() - 1
-        )
-        let cutOffIndex = null
-
-        const iterable = updates || []
-        for (let i = 0; i < iterable.length; i++) {
-          const update = iterable[i]
-          for (let user of Array.from(update.meta.users || [])) {
-            if (user != null) {
-              user.hue = ColorManager.getHueForUserId(user.id)
-            }
-          }
-          if (
-            previousUpdate == null ||
-            !moment(previousUpdate.meta.end_ts).isSame(
-              update.meta.end_ts,
-              'day'
-            )
-          ) {
-            update.meta.first_in_day = true
-          }
-
-          previousUpdate = update
+      return { text, highlights }
+    }
 
-          if (
-            !this.$scope.history.userHasFullFeature &&
-            update.meta.end_ts < timestamp24hoursAgo
-          ) {
-            cutOffIndex = i || 1 // Make sure that we show at least one entry (to allow labelling).
-            this.$scope.history.freeHistoryLimitHit = true
-            if (this.$scope.project.owner._id === this.$scope.user.id) {
-              ga(
-                'send',
-                'event',
-                'subscription-funnel',
-                'editor-click-feature',
-                'history'
-              )
-            }
-            break
+    _loadUpdates(updates) {
+      if (updates == null) {
+        updates = []
+      }
+      let previousUpdate = this.$scope.history.updates[
+        this.$scope.history.updates.length - 1
+      ]
+      const dateTimeNow = new Date()
+      const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
+      let cutOffIndex = null
+
+      const iterable = updates || []
+      for (let i = 0; i < iterable.length; i++) {
+        const update = iterable[i]
+        for (let user of Array.from(update.meta.users || [])) {
+          if (user != null) {
+            user.hue = ColorManager.getHueForUserId(user.id)
           }
         }
-
-        const firstLoad = this.$scope.history.updates.length === 0
-
-        if (!this.$scope.history.userHasFullFeature && cutOffIndex != null) {
-          updates = updates.slice(0, cutOffIndex)
+        if (
+          previousUpdate == null ||
+          !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
+        ) {
+          update.meta.first_in_day = true
         }
 
-        this.$scope.history.updates = this.$scope.history.updates.concat(
-          updates
-        )
+        previousUpdate = update
 
-        if (firstLoad) {
-          this._handleHistoryUIStateChange()
+        if (
+          !this.$scope.history.userHasFullFeature &&
+          update.meta.end_ts < timestamp24hoursAgo
+        ) {
+          cutOffIndex = i || 1 // Make sure that we show at least one entry (to allow labelling).
+          this.$scope.history.freeHistoryLimitHit = true
+          if (this.$scope.project.owner._id === this.$scope.user.id) {
+            ga(
+              'send',
+              'event',
+              'subscription-funnel',
+              'editor-click-feature',
+              'history'
+            )
+          }
+          break
         }
       }
 
-      _labelVersion(comment, version) {
-        const url = `/project/${this.$scope.project_id}/labels`
-        return this.ide.$http
-          .post(url, {
-            comment,
-            version,
-            _csrf: window.csrfToken
-          })
-          .then(response => {
-            return this._addLabelLocally(response.data)
-          })
+      const firstLoad = this.$scope.history.updates.length === 0
+
+      if (!this.$scope.history.userHasFullFeature && cutOffIndex != null) {
+        updates = updates.slice(0, cutOffIndex)
       }
 
-      _addLabelLocally(label) {
-        const localUpdate = _.find(
-          this.$scope.history.updates,
-          update => update.toV === label.version
-        )
-        if (localUpdate != null) {
-          localUpdate.labels = localUpdate.labels.concat(label)
-        }
-        this.$scope.history.labels = this._loadLabels(
-          this.$scope.history.labels.concat(label),
-          this.$scope.history.updates[0].toV
-        )
+      this.$scope.history.updates = this.$scope.history.updates.concat(updates)
+
+      if (firstLoad) {
         this._handleHistoryUIStateChange()
       }
+    }
 
-      _updateContainsUserId(update, user_id) {
-        for (let user of Array.from(update.meta.users)) {
-          if ((user != null ? user.id : undefined) === user_id) {
-            return true
-          }
+    _labelVersion(comment, version) {
+      const url = `/project/${this.$scope.project_id}/labels`
+      return this.ide.$http
+        .post(url, {
+          comment,
+          version,
+          _csrf: window.csrfToken
+        })
+        .then(response => {
+          return this._addLabelLocally(response.data)
+        })
+    }
+
+    _addLabelLocally(label) {
+      const localUpdate = _.find(
+        this.$scope.history.updates,
+        update => update.toV === label.version
+      )
+      if (localUpdate != null) {
+        localUpdate.labels = localUpdate.labels.concat(label)
+      }
+      this.$scope.history.labels = this._loadLabels(
+        this.$scope.history.labels.concat(label),
+        this.$scope.history.updates[0].toV
+      )
+      this._handleHistoryUIStateChange()
+    }
+
+    _updateContainsUserId(update, user_id) {
+      for (let user of Array.from(update.meta.users)) {
+        if ((user != null ? user.id : undefined) === user_id) {
+          return true
         }
-        return false
       }
+      return false
     }
-    HistoryManager.initClass()
-    return HistoryManager
-  })())
-})
+  }
+  HistoryManager.initClass()
+  return HistoryManager
+})())
 
 function __guard__(value, transform) {
   return typeof value !== 'undefined' && value !== null

+ 113 - 114
services/web/frontend/js/ide/history/components/historyEntriesList.js

@@ -9,131 +9,130 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], function(App) {
-  const historyEntriesListController = function($scope, $element, $attrs, _) {
-    const ctrl = this
-    ctrl.$entryListViewportEl = null
-    ctrl.isDragging = false
+import App from '../../../base'
+const historyEntriesListController = function($scope, $element, $attrs, _) {
+  const ctrl = this
+  ctrl.$entryListViewportEl = null
+  ctrl.isDragging = false
 
-    const _isEntryElVisible = function($entryEl) {
-      const entryElTop = $entryEl.offset().top
-      const entryElBottom = entryElTop + $entryEl.outerHeight()
-      const entryListViewportElTop = ctrl.$entryListViewportEl.offset().top
-      const entryListViewportElBottom =
-        entryListViewportElTop + ctrl.$entryListViewportEl.height()
+  const _isEntryElVisible = function($entryEl) {
+    const entryElTop = $entryEl.offset().top
+    const entryElBottom = entryElTop + $entryEl.outerHeight()
+    const entryListViewportElTop = ctrl.$entryListViewportEl.offset().top
+    const entryListViewportElBottom =
+      entryListViewportElTop + ctrl.$entryListViewportEl.height()
 
-      return (
-        entryElTop >= entryListViewportElTop &&
-        entryElBottom <= entryListViewportElBottom
-      )
-    }
-    const _getScrollTopPosForEntry = function($entryEl) {
-      const halfViewportElHeight = ctrl.$entryListViewportEl.height() / 2
-      return $entryEl.offset().top - halfViewportElHeight
-    }
-    ctrl.onEntryLinked = function(entry, $entryEl) {
-      if (
-        !ctrl.rangeSelectionEnabled &&
-        entry.toV === ctrl.selectedHistoryVersion
-      ) {
-        $scope.$applyAsync(() => {
-          if (!_isEntryElVisible($entryEl)) {
-            ctrl.$entryListViewportEl.scrollTop(
-              _getScrollTopPosForEntry($entryEl)
-            )
-          }
-        })
-      }
-    }
-    ctrl.handleEntrySelect = entry => {
-      if (ctrl.rangeSelectionEnabled) {
-        ctrl.onRangeSelect({
-          selectedToV: entry.toV,
-          selectedFromV: entry.fromV
-        })
-      } else {
-        ctrl.onVersionSelect({ version: entry.toV })
-      }
-    }
-    ctrl.setRangeToV = toV => {
-      if (toV > ctrl.selectedHistoryRange.fromV) {
-        ctrl.onRangeSelect({
-          selectedToV: toV,
-          selectedFromV: ctrl.selectedHistoryRange.fromV
-        })
-      }
+    return (
+      entryElTop >= entryListViewportElTop &&
+      entryElBottom <= entryListViewportElBottom
+    )
+  }
+  const _getScrollTopPosForEntry = function($entryEl) {
+    const halfViewportElHeight = ctrl.$entryListViewportEl.height() / 2
+    return $entryEl.offset().top - halfViewportElHeight
+  }
+  ctrl.onEntryLinked = function(entry, $entryEl) {
+    if (
+      !ctrl.rangeSelectionEnabled &&
+      entry.toV === ctrl.selectedHistoryVersion
+    ) {
+      $scope.$applyAsync(() => {
+        if (!_isEntryElVisible($entryEl)) {
+          ctrl.$entryListViewportEl.scrollTop(
+            _getScrollTopPosForEntry($entryEl)
+          )
+        }
+      })
     }
-    ctrl.setRangeFromV = fromV => {
-      if (fromV < ctrl.selectedHistoryRange.toV) {
-        ctrl.onRangeSelect({
-          selectedToV: ctrl.selectedHistoryRange.toV,
-          selectedFromV: fromV
-        })
-      }
+  }
+  ctrl.handleEntrySelect = entry => {
+    if (ctrl.rangeSelectionEnabled) {
+      ctrl.onRangeSelect({
+        selectedToV: entry.toV,
+        selectedFromV: entry.fromV
+      })
+    } else {
+      ctrl.onVersionSelect({ version: entry.toV })
     }
-    ctrl.initHoveredRange = () => {
-      ctrl.hoveredHistoryRange = {
-        toV: ctrl.selectedHistoryRange.toV,
-        fromV: ctrl.selectedHistoryRange.fromV
-      }
+  }
+  ctrl.setRangeToV = toV => {
+    if (toV > ctrl.selectedHistoryRange.fromV) {
+      ctrl.onRangeSelect({
+        selectedToV: toV,
+        selectedFromV: ctrl.selectedHistoryRange.fromV
+      })
     }
-    ctrl.resetHoveredRange = () => {
-      ctrl.hoveredHistoryRange = { toV: null, fromV: null }
+  }
+  ctrl.setRangeFromV = fromV => {
+    if (fromV < ctrl.selectedHistoryRange.toV) {
+      ctrl.onRangeSelect({
+        selectedToV: ctrl.selectedHistoryRange.toV,
+        selectedFromV: fromV
+      })
     }
-    ctrl.setHoveredRangeToV = toV => {
-      if (toV > ctrl.hoveredHistoryRange.fromV) {
-        $scope.$applyAsync(() => (ctrl.hoveredHistoryRange.toV = toV))
-      }
+  }
+  ctrl.initHoveredRange = () => {
+    ctrl.hoveredHistoryRange = {
+      toV: ctrl.selectedHistoryRange.toV,
+      fromV: ctrl.selectedHistoryRange.fromV
     }
-    ctrl.setHoveredRangeFromV = fromV => {
-      if (fromV < ctrl.hoveredHistoryRange.toV) {
-        $scope.$applyAsync(() => (ctrl.hoveredHistoryRange.fromV = fromV))
-      }
+  }
+  ctrl.resetHoveredRange = () => {
+    ctrl.hoveredHistoryRange = { toV: null, fromV: null }
+  }
+  ctrl.setHoveredRangeToV = toV => {
+    if (toV > ctrl.hoveredHistoryRange.fromV) {
+      $scope.$applyAsync(() => (ctrl.hoveredHistoryRange.toV = toV))
     }
-    ctrl.onDraggingStart = () => {
-      $scope.$applyAsync(() => {
-        ctrl.isDragging = true
-        ctrl.initHoveredRange()
-      })
+  }
+  ctrl.setHoveredRangeFromV = fromV => {
+    if (fromV < ctrl.hoveredHistoryRange.toV) {
+      $scope.$applyAsync(() => (ctrl.hoveredHistoryRange.fromV = fromV))
     }
-    ctrl.onDraggingStop = (isValidDrop, boundary) => {
-      $scope.$applyAsync(() => {
-        ctrl.isDragging = false
-        if (!isValidDrop) {
-          if (boundary === 'toV') {
-            ctrl.setRangeToV(ctrl.hoveredHistoryRange.toV)
-          } else if (boundary === 'fromV') {
-            ctrl.setRangeFromV(ctrl.hoveredHistoryRange.fromV)
-          }
+  }
+  ctrl.onDraggingStart = () => {
+    $scope.$applyAsync(() => {
+      ctrl.isDragging = true
+      ctrl.initHoveredRange()
+    })
+  }
+  ctrl.onDraggingStop = (isValidDrop, boundary) => {
+    $scope.$applyAsync(() => {
+      ctrl.isDragging = false
+      if (!isValidDrop) {
+        if (boundary === 'toV') {
+          ctrl.setRangeToV(ctrl.hoveredHistoryRange.toV)
+        } else if (boundary === 'fromV') {
+          ctrl.setRangeFromV(ctrl.hoveredHistoryRange.fromV)
         }
-        ctrl.resetHoveredRange()
-      })
-    }
-    ctrl.$onInit = () => {
-      ctrl.$entryListViewportEl = $element.find('> .history-entries')
+      }
       ctrl.resetHoveredRange()
-    }
+    })
+  }
+  ctrl.$onInit = () => {
+    ctrl.$entryListViewportEl = $element.find('> .history-entries')
+    ctrl.resetHoveredRange()
   }
+}
 
-  return App.component('historyEntriesList', {
-    bindings: {
-      entries: '<',
-      users: '<',
-      loadEntries: '&',
-      loadDisabled: '<',
-      loadInitialize: '<',
-      isLoading: '<',
-      currentUser: '<',
-      freeHistoryLimitHit: '<',
-      currentUserIsOwner: '<',
-      rangeSelectionEnabled: '<',
-      selectedHistoryVersion: '<?',
-      selectedHistoryRange: '<?',
-      onVersionSelect: '&',
-      onRangeSelect: '&',
-      onLabelDelete: '&'
-    },
-    controller: historyEntriesListController,
-    templateUrl: 'historyEntriesListTpl'
-  })
+export default App.component('historyEntriesList', {
+  bindings: {
+    entries: '<',
+    users: '<',
+    loadEntries: '&',
+    loadDisabled: '<',
+    loadInitialize: '<',
+    isLoading: '<',
+    currentUser: '<',
+    freeHistoryLimitHit: '<',
+    currentUserIsOwner: '<',
+    rangeSelectionEnabled: '<',
+    selectedHistoryVersion: '<?',
+    selectedHistoryRange: '<?',
+    onVersionSelect: '&',
+    onRangeSelect: '&',
+    onLabelDelete: '&'
+  },
+  controller: historyEntriesListController,
+  templateUrl: 'historyEntriesListTpl'
 })

+ 97 - 100
services/web/frontend/js/ide/history/components/historyEntry.js

@@ -9,119 +9,116 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  '../../../base',
-  '../../colors/ColorManager',
-  '../util/displayNameForUser'
-], function(App, ColorManager, displayNameForUser) {
-  const historyEntryController = function($scope, $element, $attrs, _) {
-    const ctrl = this
-    // This method (and maybe the one below) will be removed soon. User details data will be
-    // injected into the history API responses, so we won't need to fetch user data from other
-    // local data structures.
-    const _getUserById = id =>
-      _.find(ctrl.users, function(user) {
-        const curUserId =
-          (user != null ? user._id : undefined) ||
-          (user != null ? user.id : undefined)
-        return curUserId === id
-      })
-    ctrl.displayName = displayNameForUser
-    ctrl.displayNameById = id => displayNameForUser(_getUserById(id))
-    ctrl.getProjectOpDoc = function(projectOp) {
-      if (projectOp.rename != null) {
-        return `${projectOp.rename.pathname} → ${projectOp.rename.newPathname}`
-      } else if (projectOp.add != null) {
-        return `${projectOp.add.pathname}`
-      } else if (projectOp.remove != null) {
-        return `${projectOp.remove.pathname}`
-      }
-    }
-    ctrl.getUserCSSStyle = function(user) {
+import App from '../../../base'
+import ColorManager from '../../colors/ColorManager'
+import displayNameForUser from '../util/displayNameForUser'
+const historyEntryController = function($scope, $element, $attrs, _) {
+  const ctrl = this
+  // This method (and maybe the one below) will be removed soon. User details data will be
+  // injected into the history API responses, so we won't need to fetch user data from other
+  // local data structures.
+  const _getUserById = id =>
+    _.find(ctrl.users, function(user) {
       const curUserId =
         (user != null ? user._id : undefined) ||
         (user != null ? user.id : undefined)
-      const hue = ColorManager.getHueForUserId(curUserId) || 100
-      if (ctrl.isEntrySelected() || ctrl.isEntryHoverSelected()) {
-        return { color: '#FFF' }
-      } else {
-        return { color: `hsl(${hue}, 70%, 50%)` }
-      }
+      return curUserId === id
+    })
+  ctrl.displayName = displayNameForUser
+  ctrl.displayNameById = id => displayNameForUser(_getUserById(id))
+  ctrl.getProjectOpDoc = function(projectOp) {
+    if (projectOp.rename != null) {
+      return `${projectOp.rename.pathname} → ${projectOp.rename.newPathname}`
+    } else if (projectOp.add != null) {
+      return `${projectOp.add.pathname}`
+    } else if (projectOp.remove != null) {
+      return `${projectOp.remove.pathname}`
     }
-    ctrl.isEntrySelected = function() {
-      if (ctrl.rangeSelectionEnabled) {
-        return (
-          ctrl.entry.toV <= ctrl.selectedHistoryRange.toV &&
-          ctrl.entry.fromV >= ctrl.selectedHistoryRange.fromV
-        )
-      } else {
-        return ctrl.entry.toV === ctrl.selectedHistoryVersion
-      }
+  }
+  ctrl.getUserCSSStyle = function(user) {
+    const curUserId =
+      (user != null ? user._id : undefined) ||
+      (user != null ? user.id : undefined)
+    const hue = ColorManager.getHueForUserId(curUserId) || 100
+    if (ctrl.isEntrySelected() || ctrl.isEntryHoverSelected()) {
+      return { color: '#FFF' }
+    } else {
+      return { color: `hsl(${hue}, 70%, 50%)` }
     }
-
-    ctrl.isEntryHoverSelected = function() {
+  }
+  ctrl.isEntrySelected = function() {
+    if (ctrl.rangeSelectionEnabled) {
       return (
-        ctrl.rangeSelectionEnabled &&
-        ctrl.entry.toV <= ctrl.hoveredHistoryRange.toV &&
-        ctrl.entry.fromV >= ctrl.hoveredHistoryRange.fromV
+        ctrl.entry.toV <= ctrl.selectedHistoryRange.toV &&
+        ctrl.entry.fromV >= ctrl.selectedHistoryRange.fromV
       )
+    } else {
+      return ctrl.entry.toV === ctrl.selectedHistoryVersion
     }
+  }
 
-    ctrl.onDraggingStart = () => {
-      ctrl.historyEntriesList.onDraggingStart()
-    }
-    ctrl.onDraggingStop = (isValidDrop, boundary) =>
-      ctrl.historyEntriesList.onDraggingStop(isValidDrop, boundary)
+  ctrl.isEntryHoverSelected = function() {
+    return (
+      ctrl.rangeSelectionEnabled &&
+      ctrl.entry.toV <= ctrl.hoveredHistoryRange.toV &&
+      ctrl.entry.fromV >= ctrl.hoveredHistoryRange.fromV
+    )
+  }
+
+  ctrl.onDraggingStart = () => {
+    ctrl.historyEntriesList.onDraggingStart()
+  }
+  ctrl.onDraggingStop = (isValidDrop, boundary) =>
+    ctrl.historyEntriesList.onDraggingStop(isValidDrop, boundary)
 
-    ctrl.onDrop = boundary => {
-      if (boundary === 'toV') {
-        $scope.$applyAsync(() =>
-          ctrl.historyEntriesList.setRangeToV(ctrl.entry.toV)
-        )
-      } else if (boundary === 'fromV') {
-        $scope.$applyAsync(() =>
-          ctrl.historyEntriesList.setRangeFromV(ctrl.entry.fromV)
-        )
-      }
+  ctrl.onDrop = boundary => {
+    if (boundary === 'toV') {
+      $scope.$applyAsync(() =>
+        ctrl.historyEntriesList.setRangeToV(ctrl.entry.toV)
+      )
+    } else if (boundary === 'fromV') {
+      $scope.$applyAsync(() =>
+        ctrl.historyEntriesList.setRangeFromV(ctrl.entry.fromV)
+      )
     }
-    ctrl.onOver = boundary => {
-      if (boundary === 'toV') {
-        $scope.$applyAsync(() =>
-          ctrl.historyEntriesList.setHoveredRangeToV(ctrl.entry.toV)
-        )
-      } else if (boundary === 'fromV') {
-        $scope.$applyAsync(() =>
-          ctrl.historyEntriesList.setHoveredRangeFromV(ctrl.entry.fromV)
-        )
-      }
+  }
+  ctrl.onOver = boundary => {
+    if (boundary === 'toV') {
+      $scope.$applyAsync(() =>
+        ctrl.historyEntriesList.setHoveredRangeToV(ctrl.entry.toV)
+      )
+    } else if (boundary === 'fromV') {
+      $scope.$applyAsync(() =>
+        ctrl.historyEntriesList.setHoveredRangeFromV(ctrl.entry.fromV)
+      )
     }
+  }
 
-    ctrl.$onInit = () => {
-      ctrl.$entryEl = $element.find('> .history-entry')
-      ctrl.$entryDetailsEl = $element.find('.history-entry-details')
-      ctrl.$toVHandleEl = $element.find('.history-entry-toV-handle')
-      ctrl.$fromVHandleEl = $element.find('.history-entry-fromV-handle')
-      ctrl.historyEntriesList.onEntryLinked(ctrl.entry, ctrl.$entryEl)
-    }
+  ctrl.$onInit = () => {
+    ctrl.$entryEl = $element.find('> .history-entry')
+    ctrl.$entryDetailsEl = $element.find('.history-entry-details')
+    ctrl.$toVHandleEl = $element.find('.history-entry-toV-handle')
+    ctrl.$fromVHandleEl = $element.find('.history-entry-fromV-handle')
+    ctrl.historyEntriesList.onEntryLinked(ctrl.entry, ctrl.$entryEl)
   }
+}
 
-  return App.component('historyEntry', {
-    bindings: {
-      entry: '<',
-      currentUser: '<',
-      users: '<',
-      rangeSelectionEnabled: '<',
-      isDragging: '<',
-      selectedHistoryVersion: '<?',
-      selectedHistoryRange: '<?',
-      hoveredHistoryRange: '<?',
-      onSelect: '&',
-      onLabelDelete: '&'
-    },
-    require: {
-      historyEntriesList: '^historyEntriesList'
-    },
-    controller: historyEntryController,
-    templateUrl: 'historyEntryTpl'
-  })
+export default App.component('historyEntry', {
+  bindings: {
+    entry: '<',
+    currentUser: '<',
+    users: '<',
+    rangeSelectionEnabled: '<',
+    isDragging: '<',
+    selectedHistoryVersion: '<?',
+    selectedHistoryRange: '<?',
+    hoveredHistoryRange: '<?',
+    onSelect: '&',
+    onLabelDelete: '&'
+  },
+  require: {
+    historyEntriesList: '^historyEntriesList'
+  },
+  controller: historyEntryController,
+  templateUrl: 'historyEntryTpl'
 })

+ 82 - 87
services/web/frontend/js/ide/history/components/historyFileEntity.js

@@ -9,102 +9,97 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  '../../../base',
-  '../../file-tree/util/iconTypeFromName',
-  '../../file-tree/util/fileOperationI18nNames'
-], function(App, iconTypeFromName, fileOperationI18nNames) {
-  const historyFileEntityController = function($scope, $element, $attrs) {
-    const ctrl = this
-    ctrl.hasOperation = false
-    ctrl.getRenameTooltip = i18nRenamedStr => {
-      let [simplifiedOldPathname, simplifiedPathname] = _getSimplifiedPaths(
-        ctrl.fileEntity.oldPathname,
-        ctrl.fileEntity.pathname
-      )
-      return `${
-        fileOperationI18nNames.renamed
-      } <strong>${simplifiedOldPathname}</strong> &rarr; <strong>${simplifiedPathname}</strong>`
-    }
-    ctrl.getFileOperationName = () => {
-      if (ctrl.fileEntity.operation === 'edited') {
-        return fileOperationI18nNames.edited
-      } else if (ctrl.fileEntity.operation === 'renamed') {
-        return fileOperationI18nNames.renamed
-      } else if (ctrl.fileEntity.operation === 'added') {
-        return fileOperationI18nNames.created
-      } else if (ctrl.fileEntity.operation === 'removed') {
-        return fileOperationI18nNames.deleted
-      } else {
-        return ''
-      }
+import App from '../../../base'
+import iconTypeFromName from '../../file-tree/util/iconTypeFromName'
+import fileOperationI18nNames from '../../file-tree/util/fileOperationI18nNames'
+const historyFileEntityController = function($scope, $element, $attrs) {
+  const ctrl = this
+  ctrl.hasOperation = false
+  ctrl.getRenameTooltip = i18nRenamedStr => {
+    let [simplifiedOldPathname, simplifiedPathname] = _getSimplifiedPaths(
+      ctrl.fileEntity.oldPathname,
+      ctrl.fileEntity.pathname
+    )
+    return `${
+      fileOperationI18nNames.renamed
+    } <strong>${simplifiedOldPathname}</strong> &rarr; <strong>${simplifiedPathname}</strong>`
+  }
+  ctrl.getFileOperationName = () => {
+    if (ctrl.fileEntity.operation === 'edited') {
+      return fileOperationI18nNames.edited
+    } else if (ctrl.fileEntity.operation === 'renamed') {
+      return fileOperationI18nNames.renamed
+    } else if (ctrl.fileEntity.operation === 'added') {
+      return fileOperationI18nNames.created
+    } else if (ctrl.fileEntity.operation === 'removed') {
+      return fileOperationI18nNames.deleted
+    } else {
+      return ''
     }
+  }
 
-    const _getSimplifiedPaths = (path1, path2) => {
-      let path1Parts = path1.split('/')
-      let path2Parts = path2.split('/')
-      let maxIterations = Math.min(path1Parts.length, path2Parts.length) - 1
-      for (
-        var commonPartIndex = 0;
-        commonPartIndex < maxIterations;
-        commonPartIndex++
-      ) {
-        if (path1Parts[commonPartIndex] !== path2Parts[commonPartIndex]) {
-          break
-        }
+  const _getSimplifiedPaths = (path1, path2) => {
+    let path1Parts = path1.split('/')
+    let path2Parts = path2.split('/')
+    let maxIterations = Math.min(path1Parts.length, path2Parts.length) - 1
+    for (
+      var commonPartIndex = 0;
+      commonPartIndex < maxIterations;
+      commonPartIndex++
+    ) {
+      if (path1Parts[commonPartIndex] !== path2Parts[commonPartIndex]) {
+        break
       }
-      path1Parts.splice(0, commonPartIndex)
-      path2Parts.splice(0, commonPartIndex)
-      return [path1Parts.join('/'), path2Parts.join('/')]
     }
+    path1Parts.splice(0, commonPartIndex)
+    path2Parts.splice(0, commonPartIndex)
+    return [path1Parts.join('/'), path2Parts.join('/')]
+  }
 
-    const _handleFolderClick = function() {
-      ctrl.isOpen = !ctrl.isOpen
-      ctrl.entityTypeIconClass = _getFolderIcon()
-    }
+  const _handleFolderClick = function() {
+    ctrl.isOpen = !ctrl.isOpen
+    ctrl.entityTypeIconClass = _getFolderIcon()
+  }
 
-    const _handleFileClick = () =>
-      ctrl.historyFileTreeController.handleEntityClick(ctrl.fileEntity)
-    var _getFolderIcon = function() {
-      if (ctrl.isOpen) {
-        return 'fa-folder-open'
-      } else {
-        return 'fa-folder'
-      }
+  const _handleFileClick = () =>
+    ctrl.historyFileTreeController.handleEntityClick(ctrl.fileEntity)
+  var _getFolderIcon = function() {
+    if (ctrl.isOpen) {
+      return 'fa-folder-open'
+    } else {
+      return 'fa-folder'
     }
-    ctrl.$onInit = function() {
-      if (ctrl.fileEntity.type === 'folder') {
-        ctrl.isOpen = true
-        ctrl.entityTypeIconClass = _getFolderIcon()
-        ctrl.handleClick = _handleFolderClick
-      } else {
-        if (ctrl.fileEntity.operation) {
-          ctrl.hasOperation = true
-        }
-        ctrl.entityTypeIconClass = `fa-${iconTypeFromName(
-          ctrl.fileEntity.name
-        )}`
-        ctrl.entityOpTextClass = ctrl.fileEntity.operation
-          ? `history-file-entity-name-${ctrl.fileEntity.operation}`
-          : null
-        ctrl.handleClick = _handleFileClick
-        $scope.$watch(
-          () => ctrl.historyFileTreeController.selectedPathname,
-          selectedPathname =>
-            (ctrl.isSelected = ctrl.fileEntity.pathname === selectedPathname)
-        )
+  }
+  ctrl.$onInit = function() {
+    if (ctrl.fileEntity.type === 'folder') {
+      ctrl.isOpen = true
+      ctrl.entityTypeIconClass = _getFolderIcon()
+      ctrl.handleClick = _handleFolderClick
+    } else {
+      if (ctrl.fileEntity.operation) {
+        ctrl.hasOperation = true
       }
+      ctrl.entityTypeIconClass = `fa-${iconTypeFromName(ctrl.fileEntity.name)}`
+      ctrl.entityOpTextClass = ctrl.fileEntity.operation
+        ? `history-file-entity-name-${ctrl.fileEntity.operation}`
+        : null
+      ctrl.handleClick = _handleFileClick
+      $scope.$watch(
+        () => ctrl.historyFileTreeController.selectedPathname,
+        selectedPathname =>
+          (ctrl.isSelected = ctrl.fileEntity.pathname === selectedPathname)
+      )
     }
   }
+}
 
-  return App.component('historyFileEntity', {
-    require: {
-      historyFileTreeController: '^historyFileTree'
-    },
-    bindings: {
-      fileEntity: '<'
-    },
-    controller: historyFileEntityController,
-    templateUrl: 'historyFileEntityTpl'
-  })
+export default App.component('historyFileEntity', {
+  require: {
+    historyFileTreeController: '^historyFileTree'
+  },
+  bindings: {
+    fileEntity: '<'
+  },
+  controller: historyFileEntityController,
+  templateUrl: 'historyFileEntityTpl'
 })

+ 45 - 46
services/web/frontend/js/ide/history/components/historyFileTree.js

@@ -7,58 +7,57 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], function(App) {
-  const historyFileTreeController = function($scope, $element, $attrs, _) {
-    const ctrl = this
-    ctrl.handleEntityClick = file => ctrl.onSelectedFileChange({ file })
-    ctrl._fileTree = []
+import App from '../../../base'
+const historyFileTreeController = function($scope, $element, $attrs, _) {
+  const ctrl = this
+  ctrl.handleEntityClick = file => ctrl.onSelectedFileChange({ file })
+  ctrl._fileTree = []
 
-    $scope.$watch('$ctrl.files', function(files) {
-      if (files != null && files.length > 0) {
-        ctrl._fileTree = _.reduce(files, _reducePathsToTree, [])
-      }
-    })
+  $scope.$watch('$ctrl.files', function(files) {
+    if (files != null && files.length > 0) {
+      ctrl._fileTree = _.reduce(files, _reducePathsToTree, [])
+    }
+  })
 
-    function _reducePathsToTree(currentFileTree, fileObject) {
-      const filePathParts = fileObject.pathname.split('/')
-      let currentFileTreeLocation = currentFileTree
-      for (let index = 0; index < filePathParts.length; index++) {
-        var fileTreeEntity
-        var pathPart = filePathParts[index]
-        const isFile = index === filePathParts.length - 1
-        if (isFile) {
-          fileTreeEntity = _.clone(fileObject)
-          fileTreeEntity.name = pathPart
-          fileTreeEntity.type = 'file'
-          currentFileTreeLocation.push(fileTreeEntity)
-        } else {
-          fileTreeEntity = _.find(
-            currentFileTreeLocation,
-            entity => entity.name === pathPart
-          )
-          if (fileTreeEntity == null) {
-            fileTreeEntity = {
-              name: pathPart,
-              type: 'folder',
-              children: []
-            }
-            currentFileTreeLocation.push(fileTreeEntity)
+  function _reducePathsToTree(currentFileTree, fileObject) {
+    const filePathParts = fileObject.pathname.split('/')
+    let currentFileTreeLocation = currentFileTree
+    for (let index = 0; index < filePathParts.length; index++) {
+      var fileTreeEntity
+      var pathPart = filePathParts[index]
+      const isFile = index === filePathParts.length - 1
+      if (isFile) {
+        fileTreeEntity = _.clone(fileObject)
+        fileTreeEntity.name = pathPart
+        fileTreeEntity.type = 'file'
+        currentFileTreeLocation.push(fileTreeEntity)
+      } else {
+        fileTreeEntity = _.find(
+          currentFileTreeLocation,
+          entity => entity.name === pathPart
+        )
+        if (fileTreeEntity == null) {
+          fileTreeEntity = {
+            name: pathPart,
+            type: 'folder',
+            children: []
           }
-          currentFileTreeLocation = fileTreeEntity.children
+          currentFileTreeLocation.push(fileTreeEntity)
         }
+        currentFileTreeLocation = fileTreeEntity.children
       }
-      return currentFileTree
     }
+    return currentFileTree
   }
+}
 
-  return App.component('historyFileTree', {
-    bindings: {
-      files: '<',
-      selectedPathname: '<',
-      onSelectedFileChange: '&',
-      isLoading: '<'
-    },
-    controller: historyFileTreeController,
-    templateUrl: 'historyFileTreeTpl'
-  })
+export default App.component('historyFileTree', {
+  bindings: {
+    files: '<',
+    selectedPathname: '<',
+    onSelectedFileChange: '&',
+    isLoading: '<'
+  },
+  controller: historyFileTreeController,
+  templateUrl: 'historyFileTreeTpl'
 })

+ 22 - 29
services/web/frontend/js/ide/history/components/historyLabel.js

@@ -9,36 +9,29 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], function(App) {
-  const historyLabelController = function(
-    $scope,
-    $element,
-    $attrs,
-    $filter,
-    _
-  ) {
-    const ctrl = this
-    ctrl.$onInit = () => {
-      if (ctrl.showTooltip == null) {
-        ctrl.showTooltip = true
-      }
-      if (ctrl.isPseudoCurrentStateLabel == null) {
-        ctrl.isPseudoCurrentStateLabel = false
-      }
+import App from '../../../base'
+const historyLabelController = function($scope, $element, $attrs, $filter, _) {
+  const ctrl = this
+  ctrl.$onInit = () => {
+    if (ctrl.showTooltip == null) {
+      ctrl.showTooltip = true
+    }
+    if (ctrl.isPseudoCurrentStateLabel == null) {
+      ctrl.isPseudoCurrentStateLabel = false
     }
   }
+}
 
-  return App.component('historyLabel', {
-    bindings: {
-      labelText: '<',
-      labelOwnerName: '<?',
-      labelCreationDateTime: '<?',
-      isOwnedByCurrentUser: '<',
-      isPseudoCurrentStateLabel: '<',
-      onLabelDelete: '&',
-      showTooltip: '<?'
-    },
-    controller: historyLabelController,
-    templateUrl: 'historyLabelTpl'
-  })
+export default App.component('historyLabel', {
+  bindings: {
+    labelText: '<',
+    labelOwnerName: '<?',
+    labelCreationDateTime: '<?',
+    isOwnedByCurrentUser: '<',
+    isPseudoCurrentStateLabel: '<',
+    onLabelDelete: '&',
+    showTooltip: '<?'
+  },
+  controller: historyLabelController,
+  templateUrl: 'historyLabelTpl'
 })

+ 143 - 149
services/web/frontend/js/ide/history/components/historyLabelsList.js

@@ -9,175 +9,169 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([
-  '../../../base',
-  '../../colors/ColorManager',
-  '../util/displayNameForUser'
-], function(App, ColorManager, displayNameForUser) {
-  const historyLabelsListController = function($scope, $element, $attrs, _) {
-    const ctrl = this
-    ctrl.isDragging = false
-    ctrl.versionsWithLabels = []
-    $scope.$watchCollection('$ctrl.labels', function(labels) {
-      if (labels != null && labels.length > 0) {
-        const groupedLabelsHash = _.groupBy(labels, 'version')
-        ctrl.versionsWithLabels = _.map(
-          groupedLabelsHash,
-          (labels, version) => {
-            return {
-              version: parseInt(version, 10),
-              labels
-            }
-          }
-        )
-      }
-    })
-    ctrl.initHoveredRange = () => {
-      ctrl.hoveredHistoryRange = {
-        toV: ctrl.selectedHistoryRange.toV,
-        fromV: ctrl.selectedHistoryRange.fromV
-      }
+import App from '../../../base'
+import ColorManager from '../../colors/ColorManager'
+import displayNameForUser from '../util/displayNameForUser'
+const historyLabelsListController = function($scope, $element, $attrs, _) {
+  const ctrl = this
+  ctrl.isDragging = false
+  ctrl.versionsWithLabels = []
+  $scope.$watchCollection('$ctrl.labels', function(labels) {
+    if (labels != null && labels.length > 0) {
+      const groupedLabelsHash = _.groupBy(labels, 'version')
+      ctrl.versionsWithLabels = _.map(groupedLabelsHash, (labels, version) => {
+        return {
+          version: parseInt(version, 10),
+          labels
+        }
+      })
     }
-    ctrl.resetHoveredRange = () => {
-      ctrl.hoveredHistoryRange = { toV: null, fromV: null }
+  })
+  ctrl.initHoveredRange = () => {
+    ctrl.hoveredHistoryRange = {
+      toV: ctrl.selectedHistoryRange.toV,
+      fromV: ctrl.selectedHistoryRange.fromV
     }
-    ctrl.setHoveredRangeToV = toV => {
-      if (toV >= ctrl.hoveredHistoryRange.fromV) {
-        ctrl.hoveredHistoryRange.toV = toV
-      }
+  }
+  ctrl.resetHoveredRange = () => {
+    ctrl.hoveredHistoryRange = { toV: null, fromV: null }
+  }
+  ctrl.setHoveredRangeToV = toV => {
+    if (toV >= ctrl.hoveredHistoryRange.fromV) {
+      ctrl.hoveredHistoryRange.toV = toV
     }
-    ctrl.setHoveredRangeFromV = fromV => {
-      if (fromV <= ctrl.hoveredHistoryRange.toV) {
-        ctrl.hoveredHistoryRange.fromV = fromV
-      }
+  }
+  ctrl.setHoveredRangeFromV = fromV => {
+    if (fromV <= ctrl.hoveredHistoryRange.toV) {
+      ctrl.hoveredHistoryRange.fromV = fromV
     }
+  }
 
-    ctrl.isVersionSelected = function(version) {
-      if (ctrl.rangeSelectionEnabled) {
-        return (
-          version <= ctrl.selectedHistoryRange.toV &&
-          version >= ctrl.selectedHistoryRange.fromV
-        )
-      } else {
-        return version === ctrl.selectedHistoryVersion
-      }
-    }
-    ctrl.isVersionHoverSelected = function(version) {
+  ctrl.isVersionSelected = function(version) {
+    if (ctrl.rangeSelectionEnabled) {
       return (
-        ctrl.rangeSelectionEnabled &&
-        version <= ctrl.hoveredHistoryRange.toV &&
-        version >= ctrl.hoveredHistoryRange.fromV
+        version <= ctrl.selectedHistoryRange.toV &&
+        version >= ctrl.selectedHistoryRange.fromV
       )
+    } else {
+      return version === ctrl.selectedHistoryVersion
     }
-    ctrl.onDraggingStart = () => {
-      $scope.$applyAsync(() => {
-        ctrl.isDragging = true
-        ctrl.initHoveredRange()
-      })
-    }
-    ctrl.onDraggingStop = (isValidDrop, boundary) => {
-      $scope.$applyAsync(() => {
-        if (!isValidDrop) {
-          if (boundary === 'toV') {
-            ctrl.setRangeToV(ctrl.hoveredHistoryRange.toV)
-          } else if (boundary === 'fromV') {
-            ctrl.setRangeFromV(ctrl.hoveredHistoryRange.fromV)
-          }
+  }
+  ctrl.isVersionHoverSelected = function(version) {
+    return (
+      ctrl.rangeSelectionEnabled &&
+      version <= ctrl.hoveredHistoryRange.toV &&
+      version >= ctrl.hoveredHistoryRange.fromV
+    )
+  }
+  ctrl.onDraggingStart = () => {
+    $scope.$applyAsync(() => {
+      ctrl.isDragging = true
+      ctrl.initHoveredRange()
+    })
+  }
+  ctrl.onDraggingStop = (isValidDrop, boundary) => {
+    $scope.$applyAsync(() => {
+      if (!isValidDrop) {
+        if (boundary === 'toV') {
+          ctrl.setRangeToV(ctrl.hoveredHistoryRange.toV)
+        } else if (boundary === 'fromV') {
+          ctrl.setRangeFromV(ctrl.hoveredHistoryRange.fromV)
         }
-        ctrl.isDragging = false
-        ctrl.resetHoveredRange()
-      })
-    }
-    ctrl.onDrop = (boundary, versionWithLabel) => {
-      if (boundary === 'toV') {
-        $scope.$applyAsync(() => ctrl.setRangeToV(versionWithLabel.version))
-      } else if (boundary === 'fromV') {
-        $scope.$applyAsync(() => ctrl.setRangeFromV(versionWithLabel.version))
-      }
-    }
-    ctrl.onOver = (boundary, versionWithLabel) => {
-      if (boundary === 'toV') {
-        $scope.$applyAsync(() =>
-          ctrl.setHoveredRangeToV(versionWithLabel.version)
-        )
-      } else if (boundary === 'fromV') {
-        $scope.$applyAsync(() =>
-          ctrl.setHoveredRangeFromV(versionWithLabel.version)
-        )
       }
+      ctrl.isDragging = false
+      ctrl.resetHoveredRange()
+    })
+  }
+  ctrl.onDrop = (boundary, versionWithLabel) => {
+    if (boundary === 'toV') {
+      $scope.$applyAsync(() => ctrl.setRangeToV(versionWithLabel.version))
+    } else if (boundary === 'fromV') {
+      $scope.$applyAsync(() => ctrl.setRangeFromV(versionWithLabel.version))
     }
-    ctrl.handleVersionSelect = versionWithLabel => {
-      if (ctrl.rangeSelectionEnabled) {
-        // TODO
-        ctrl.onRangeSelect({
-          selectedToV: versionWithLabel.version,
-          selectedFromV: versionWithLabel.version
-        })
-      } else {
-        ctrl.onVersionSelect({ version: versionWithLabel.version })
-      }
+  }
+  ctrl.onOver = (boundary, versionWithLabel) => {
+    if (boundary === 'toV') {
+      $scope.$applyAsync(() =>
+        ctrl.setHoveredRangeToV(versionWithLabel.version)
+      )
+    } else if (boundary === 'fromV') {
+      $scope.$applyAsync(() =>
+        ctrl.setHoveredRangeFromV(versionWithLabel.version)
+      )
     }
-    ctrl.setRangeToV = version => {
-      if (version >= ctrl.selectedHistoryRange.fromV) {
-        ctrl.onRangeSelect({
-          selectedToV: version,
-          selectedFromV: ctrl.selectedHistoryRange.fromV
-        })
-      }
+  }
+  ctrl.handleVersionSelect = versionWithLabel => {
+    if (ctrl.rangeSelectionEnabled) {
+      // TODO
+      ctrl.onRangeSelect({
+        selectedToV: versionWithLabel.version,
+        selectedFromV: versionWithLabel.version
+      })
+    } else {
+      ctrl.onVersionSelect({ version: versionWithLabel.version })
     }
-    ctrl.setRangeFromV = version => {
-      if (version <= ctrl.selectedHistoryRange.toV) {
-        ctrl.onRangeSelect({
-          selectedToV: ctrl.selectedHistoryRange.toV,
-          selectedFromV: version
-        })
-      }
+  }
+  ctrl.setRangeToV = version => {
+    if (version >= ctrl.selectedHistoryRange.fromV) {
+      ctrl.onRangeSelect({
+        selectedToV: version,
+        selectedFromV: ctrl.selectedHistoryRange.fromV
+      })
     }
-    // This method (and maybe the one below) will be removed soon. User details data will be
-    // injected into the history API responses, so we won't need to fetch user data from other
-    // local data structures.
-    ctrl.getUserById = id =>
-      _.find(ctrl.users, function(user) {
-        const curUserId =
-          (user != null ? user._id : undefined) ||
-          (user != null ? user.id : undefined)
-        return curUserId === id
+  }
+  ctrl.setRangeFromV = version => {
+    if (version <= ctrl.selectedHistoryRange.toV) {
+      ctrl.onRangeSelect({
+        selectedToV: ctrl.selectedHistoryRange.toV,
+        selectedFromV: version
       })
-    ctrl.displayName = displayNameForUser
-    ctrl.getUserCSSStyle = function(user, versionWithLabel) {
+    }
+  }
+  // This method (and maybe the one below) will be removed soon. User details data will be
+  // injected into the history API responses, so we won't need to fetch user data from other
+  // local data structures.
+  ctrl.getUserById = id =>
+    _.find(ctrl.users, function(user) {
       const curUserId =
         (user != null ? user._id : undefined) ||
         (user != null ? user.id : undefined)
-      const hue = ColorManager.getHueForUserId(curUserId) || 100
-      if (
-        ctrl.isVersionSelected(versionWithLabel.version) ||
-        ctrl.isVersionHoverSelected(versionWithLabel.version)
-      ) {
-        return { color: '#FFF' }
-      } else {
-        return { color: `hsl(${hue}, 70%, 50%)` }
-      }
+      return curUserId === id
+    })
+  ctrl.displayName = displayNameForUser
+  ctrl.getUserCSSStyle = function(user, versionWithLabel) {
+    const curUserId =
+      (user != null ? user._id : undefined) ||
+      (user != null ? user.id : undefined)
+    const hue = ColorManager.getHueForUserId(curUserId) || 100
+    if (
+      ctrl.isVersionSelected(versionWithLabel.version) ||
+      ctrl.isVersionHoverSelected(versionWithLabel.version)
+    ) {
+      return { color: '#FFF' }
+    } else {
+      return { color: `hsl(${hue}, 70%, 50%)` }
     }
+  }
 
-    ctrl.$onInit = () => {
-      ctrl.resetHoveredRange()
-    }
+  ctrl.$onInit = () => {
+    ctrl.resetHoveredRange()
   }
+}
 
-  return App.component('historyLabelsList', {
-    bindings: {
-      labels: '<',
-      rangeSelectionEnabled: '<',
-      users: '<',
-      currentUser: '<',
-      isLoading: '<',
-      selectedHistoryVersion: '<?',
-      selectedHistoryRange: '<?',
-      onVersionSelect: '&',
-      onRangeSelect: '&',
-      onLabelDelete: '&'
-    },
-    controller: historyLabelsListController,
-    templateUrl: 'historyLabelsListTpl'
-  })
+export default App.component('historyLabelsList', {
+  bindings: {
+    labels: '<',
+    rangeSelectionEnabled: '<',
+    users: '<',
+    currentUser: '<',
+    isLoading: '<',
+    selectedHistoryVersion: '<?',
+    selectedHistoryRange: '<?',
+    onVersionSelect: '&',
+    onRangeSelect: '&',
+    onLabelDelete: '&'
+  },
+  controller: historyLabelsListController,
+  templateUrl: 'historyLabelsListTpl'
 })

+ 52 - 53
services/web/frontend/js/ide/history/controllers/HistoryDiffController.js

@@ -10,64 +10,63 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], function(App) {
-  App.controller('HistoryDiffController', function(
-    $scope,
-    $modal,
-    ide,
-    eventTracking
-  ) {
-    $scope.restoreDeletedDoc = function() {
-      eventTracking.sendMB('history-restore-deleted')
-      $scope.history.diff.restoreInProgress = true
-      return ide.historyManager
-        .restoreDeletedDoc($scope.history.diff.doc)
-        .then(function(response) {
-          const { data } = response
-          $scope.history.diff.restoredDocNewId = data.doc_id
-          $scope.history.diff.restoreInProgress = false
-          return ($scope.history.diff.restoreDeletedSuccess = true)
-        })
-    }
+import App from '../../../base'
+App.controller('HistoryDiffController', function(
+  $scope,
+  $modal,
+  ide,
+  eventTracking
+) {
+  $scope.restoreDeletedDoc = function() {
+    eventTracking.sendMB('history-restore-deleted')
+    $scope.history.diff.restoreInProgress = true
+    return ide.historyManager
+      .restoreDeletedDoc($scope.history.diff.doc)
+      .then(function(response) {
+        const { data } = response
+        $scope.history.diff.restoredDocNewId = data.doc_id
+        $scope.history.diff.restoreInProgress = false
+        return ($scope.history.diff.restoreDeletedSuccess = true)
+      })
+  }
 
-    $scope.openRestoreDiffModal = function() {
-      eventTracking.sendMB('history-restore-modal')
-      return $modal.open({
-        templateUrl: 'historyRestoreDiffModalTemplate',
-        controller: 'HistoryRestoreDiffModalController',
-        resolve: {
-          diff() {
-            return $scope.history.diff
-          }
+  $scope.openRestoreDiffModal = function() {
+    eventTracking.sendMB('history-restore-modal')
+    return $modal.open({
+      templateUrl: 'historyRestoreDiffModalTemplate',
+      controller: 'HistoryRestoreDiffModalController',
+      resolve: {
+        diff() {
+          return $scope.history.diff
         }
-      })
-    }
+      }
+    })
+  }
 
-    return ($scope.backToEditorAfterRestore = () =>
-      ide.editorManager.openDoc({ id: $scope.history.diff.restoredDocNewId }))
-  })
+  return ($scope.backToEditorAfterRestore = () =>
+    ide.editorManager.openDoc({ id: $scope.history.diff.restoredDocNewId }))
+})
 
-  return App.controller('HistoryRestoreDiffModalController', function(
-    $scope,
-    $modalInstance,
-    diff,
-    ide,
-    eventTracking
-  ) {
-    $scope.state = { inflight: false }
+export default App.controller('HistoryRestoreDiffModalController', function(
+  $scope,
+  $modalInstance,
+  diff,
+  ide,
+  eventTracking
+) {
+  $scope.state = { inflight: false }
 
-    $scope.diff = diff
+  $scope.diff = diff
 
-    $scope.restore = function() {
-      eventTracking.sendMB('history-restored')
-      $scope.state.inflight = true
-      return ide.historyManager.restoreDiff(diff).then(function() {
-        $scope.state.inflight = false
-        $modalInstance.close()
-        return ide.editorManager.openDoc(diff.doc)
-      })
-    }
+  $scope.restore = function() {
+    eventTracking.sendMB('history-restored')
+    $scope.state.inflight = true
+    return ide.historyManager.restoreDiff(diff).then(function() {
+      $scope.state.inflight = false
+      $modalInstance.close()
+      return ide.editorManager.openDoc(diff.doc)
+    })
+  }
 
-    return ($scope.cancel = () => $modalInstance.dismiss())
-  })
+  return ($scope.cancel = () => $modalInstance.dismiss())
 })

+ 162 - 168
services/web/frontend/js/ide/history/controllers/HistoryListController.js

@@ -13,204 +13,198 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base', '../util/displayNameForUser'], function(
-  App,
-  displayNameForUser
-) {
-  App.controller('HistoryListController', function($scope, $modal, ide) {
-    $scope.hoveringOverListSelectors = false
+import App from '../../../base'
+import displayNameForUser from '../util/displayNameForUser'
+App.controller('HistoryListController', function($scope, $modal, ide) {
+  $scope.hoveringOverListSelectors = false
 
-    $scope.projectUsers = []
+  $scope.projectUsers = []
 
-    $scope.$watch('project.members', function(newVal) {
-      if (newVal != null) {
-        return ($scope.projectUsers = newVal.concat($scope.project.owner))
-      }
+  $scope.$watch('project.members', function(newVal) {
+    if (newVal != null) {
+      return ($scope.projectUsers = newVal.concat($scope.project.owner))
+    }
+  })
+
+  // This method (and maybe the one below) will be removed soon. User details data will be
+  // injected into the history API responses, so we won't need to fetch user data from other
+  // local data structures.
+  const _getUserById = id =>
+    _.find($scope.projectUsers, function(user) {
+      const curUserId =
+        (user != null ? user._id : undefined) ||
+        (user != null ? user.id : undefined)
+      return curUserId === id
     })
 
-    // This method (and maybe the one below) will be removed soon. User details data will be
-    // injected into the history API responses, so we won't need to fetch user data from other
-    // local data structures.
-    const _getUserById = id =>
-      _.find($scope.projectUsers, function(user) {
-        const curUserId =
-          (user != null ? user._id : undefined) ||
-          (user != null ? user.id : undefined)
-        return curUserId === id
-      })
-
-    $scope.getDisplayNameById = id => displayNameForUser(_getUserById(id))
-
-    $scope.deleteLabel = labelDetails =>
-      $modal.open({
-        templateUrl: 'historyV2DeleteLabelModalTemplate',
-        controller: 'HistoryV2DeleteLabelModalController',
-        resolve: {
-          labelDetails() {
-            return labelDetails
-          }
-        }
-      })
+  $scope.getDisplayNameById = id => displayNameForUser(_getUserById(id))
 
-    $scope.loadMore = () => {
-      return ide.historyManager.fetchNextBatchOfUpdates()
-    }
+  $scope.deleteLabel = labelDetails =>
+    $modal.open({
+      templateUrl: 'historyV2DeleteLabelModalTemplate',
+      controller: 'HistoryV2DeleteLabelModalController',
+      resolve: {
+        labelDetails() {
+          return labelDetails
+        }
+      }
+    })
 
-    $scope.recalculateSelectedUpdates = function() {
-      let beforeSelection = true
-      let afterSelection = false
-      $scope.history.selection.updates = []
-      return (() => {
-        const result = []
-        for (let update of Array.from($scope.history.updates)) {
-          var inSelection
-          if (update.selectedTo) {
-            inSelection = true
-            beforeSelection = false
-          }
+  $scope.loadMore = () => {
+    return ide.historyManager.fetchNextBatchOfUpdates()
+  }
+
+  $scope.recalculateSelectedUpdates = function() {
+    let beforeSelection = true
+    let afterSelection = false
+    $scope.history.selection.updates = []
+    return (() => {
+      const result = []
+      for (let update of Array.from($scope.history.updates)) {
+        var inSelection
+        if (update.selectedTo) {
+          inSelection = true
+          beforeSelection = false
+        }
 
-          update.beforeSelection = beforeSelection
-          update.inSelection = inSelection
-          update.afterSelection = afterSelection
+        update.beforeSelection = beforeSelection
+        update.inSelection = inSelection
+        update.afterSelection = afterSelection
 
-          if (inSelection) {
-            $scope.history.selection.updates.push(update)
-          }
+        if (inSelection) {
+          $scope.history.selection.updates.push(update)
+        }
 
-          if (update.selectedFrom) {
-            inSelection = false
-            result.push((afterSelection = true))
-          } else {
-            result.push(undefined)
-          }
+        if (update.selectedFrom) {
+          inSelection = false
+          result.push((afterSelection = true))
+        } else {
+          result.push(undefined)
         }
-        return result
-      })()
+      }
+      return result
+    })()
+  }
+
+  $scope.recalculateHoveredUpdates = function() {
+    let inHoverSelection
+    let hoverSelectedFrom = false
+    let hoverSelectedTo = false
+    for (var update of Array.from($scope.history.updates)) {
+      // Figure out whether the to or from selector is hovered over
+      if (update.hoverSelectedFrom) {
+        hoverSelectedFrom = true
+      }
+      if (update.hoverSelectedTo) {
+        hoverSelectedTo = true
+      }
     }
 
-    $scope.recalculateHoveredUpdates = function() {
-      let inHoverSelection
-      let hoverSelectedFrom = false
-      let hoverSelectedTo = false
-      for (var update of Array.from($scope.history.updates)) {
-        // Figure out whether the to or from selector is hovered over
-        if (update.hoverSelectedFrom) {
-          hoverSelectedFrom = true
+    if (hoverSelectedFrom) {
+      // We want to 'hover select' everything between hoverSelectedFrom and selectedTo
+      inHoverSelection = false
+      for (update of Array.from($scope.history.updates)) {
+        if (update.selectedTo) {
+          update.hoverSelectedTo = true
+          inHoverSelection = true
         }
-        if (update.hoverSelectedTo) {
-          hoverSelectedTo = true
+        update.inHoverSelection = inHoverSelection
+        if (update.hoverSelectedFrom) {
+          inHoverSelection = false
         }
       }
-
-      if (hoverSelectedFrom) {
-        // We want to 'hover select' everything between hoverSelectedFrom and selectedTo
-        inHoverSelection = false
+    }
+    if (hoverSelectedTo) {
+      // We want to 'hover select' everything between hoverSelectedTo and selectedFrom
+      inHoverSelection = false
+      return (() => {
+        const result = []
         for (update of Array.from($scope.history.updates)) {
-          if (update.selectedTo) {
-            update.hoverSelectedTo = true
+          if (update.hoverSelectedTo) {
             inHoverSelection = true
           }
           update.inHoverSelection = inHoverSelection
-          if (update.hoverSelectedFrom) {
-            inHoverSelection = false
-          }
-        }
-      }
-      if (hoverSelectedTo) {
-        // We want to 'hover select' everything between hoverSelectedTo and selectedFrom
-        inHoverSelection = false
-        return (() => {
-          const result = []
-          for (update of Array.from($scope.history.updates)) {
-            if (update.hoverSelectedTo) {
-              inHoverSelection = true
-            }
-            update.inHoverSelection = inHoverSelection
-            if (update.selectedFrom) {
-              update.hoverSelectedFrom = true
-              result.push((inHoverSelection = false))
-            } else {
-              result.push(undefined)
-            }
+          if (update.selectedFrom) {
+            update.hoverSelectedFrom = true
+            result.push((inHoverSelection = false))
+          } else {
+            result.push(undefined)
           }
-          return result
-        })()
-      }
-    }
-
-    $scope.resetHoverState = () =>
-      (() => {
-        const result = []
-        for (let update of Array.from($scope.history.updates)) {
-          delete update.hoverSelectedFrom
-          delete update.hoverSelectedTo
-          result.push(delete update.inHoverSelection)
         }
         return result
       })()
+    }
+  }
+
+  $scope.resetHoverState = () =>
+    (() => {
+      const result = []
+      for (let update of Array.from($scope.history.updates)) {
+        delete update.hoverSelectedFrom
+        delete update.hoverSelectedTo
+        result.push(delete update.inHoverSelection)
+      }
+      return result
+    })()
 
-    return $scope.$watch('history.updates.length', () =>
-      $scope.recalculateSelectedUpdates()
-    )
-  })
+  return $scope.$watch('history.updates.length', () =>
+    $scope.recalculateSelectedUpdates()
+  )
+})
 
-  return App.controller('HistoryListItemController', function(
-    $scope,
-    eventTracking
-  ) {
-    $scope.$watch('update.selectedFrom', function(
-      selectedFrom,
-      oldSelectedFrom
-    ) {
-      if (selectedFrom) {
-        for (let update of Array.from($scope.history.updates)) {
-          if (update !== $scope.update) {
-            update.selectedFrom = false
-          }
+export default App.controller('HistoryListItemController', function(
+  $scope,
+  eventTracking
+) {
+  $scope.$watch('update.selectedFrom', function(selectedFrom, oldSelectedFrom) {
+    if (selectedFrom) {
+      for (let update of Array.from($scope.history.updates)) {
+        if (update !== $scope.update) {
+          update.selectedFrom = false
         }
-        return $scope.recalculateSelectedUpdates()
       }
-    })
+      return $scope.recalculateSelectedUpdates()
+    }
+  })
 
-    $scope.$watch('update.selectedTo', function(selectedTo, oldSelectedTo) {
-      if (selectedTo) {
-        for (let update of Array.from($scope.history.updates)) {
-          if (update !== $scope.update) {
-            update.selectedTo = false
-          }
+  $scope.$watch('update.selectedTo', function(selectedTo, oldSelectedTo) {
+    if (selectedTo) {
+      for (let update of Array.from($scope.history.updates)) {
+        if (update !== $scope.update) {
+          update.selectedTo = false
         }
-        return $scope.recalculateSelectedUpdates()
       }
-    })
-
-    $scope.select = function() {
-      eventTracking.sendMB('history-view-change')
-      $scope.update.selectedTo = true
-      return ($scope.update.selectedFrom = true)
-    }
-
-    $scope.mouseOverSelectedFrom = function() {
-      $scope.history.hoveringOverListSelectors = true
-      $scope.update.hoverSelectedFrom = true
-      return $scope.recalculateHoveredUpdates()
+      return $scope.recalculateSelectedUpdates()
     }
-
-    $scope.mouseOutSelectedFrom = function() {
-      $scope.history.hoveringOverListSelectors = false
-      return $scope.resetHoverState()
-    }
-
-    $scope.mouseOverSelectedTo = function() {
-      $scope.history.hoveringOverListSelectors = true
-      $scope.update.hoverSelectedTo = true
-      return $scope.recalculateHoveredUpdates()
-    }
-
-    $scope.mouseOutSelectedTo = function() {
-      $scope.history.hoveringOverListSelectors = false
-      return $scope.resetHoverState()
-    }
-
-    return ($scope.displayName = displayNameForUser)
   })
+
+  $scope.select = function() {
+    eventTracking.sendMB('history-view-change')
+    $scope.update.selectedTo = true
+    return ($scope.update.selectedFrom = true)
+  }
+
+  $scope.mouseOverSelectedFrom = function() {
+    $scope.history.hoveringOverListSelectors = true
+    $scope.update.hoverSelectedFrom = true
+    return $scope.recalculateHoveredUpdates()
+  }
+
+  $scope.mouseOutSelectedFrom = function() {
+    $scope.history.hoveringOverListSelectors = false
+    return $scope.resetHoverState()
+  }
+
+  $scope.mouseOverSelectedTo = function() {
+    $scope.history.hoveringOverListSelectors = true
+    $scope.update.hoverSelectedTo = true
+    return $scope.recalculateHoveredUpdates()
+  }
+
+  $scope.mouseOutSelectedTo = function() {
+    $scope.history.hoveringOverListSelectors = false
+    return $scope.resetHoverState()
+  }
+
+  return ($scope.displayName = displayNameForUser)
 })

+ 36 - 35
services/web/frontend/js/ide/history/controllers/HistoryV2AddLabelModalController.js

@@ -9,40 +9,41 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('HistoryV2AddLabelModalController', function(
-    $scope,
-    $modalInstance,
-    ide,
-    update
-  ) {
-    $scope.update = update
-    $scope.inputs = { labelName: null }
-    $scope.state = {
-      inflight: false,
-      error: false
-    }
+import App from '../../../base'
 
-    $modalInstance.opened.then(() =>
-      $scope.$applyAsync(() => $scope.$broadcast('open'))
-    )
+export default App.controller('HistoryV2AddLabelModalController', function(
+  $scope,
+  $modalInstance,
+  ide,
+  update
+) {
+  $scope.update = update
+  $scope.inputs = { labelName: null }
+  $scope.state = {
+    inflight: false,
+    error: false
+  }
 
-    return ($scope.addLabelModalFormSubmit = function() {
-      $scope.state.inflight = true
-      return ide.historyManager
-        .labelCurrentVersion($scope.inputs.labelName)
-        .then(function(response) {
-          $scope.state.inflight = false
-          return $modalInstance.close()
-        })
-        .catch(function(response) {
-          const { data, status } = response
-          $scope.state.inflight = false
-          if (status === 400) {
-            return ($scope.state.error = { message: data })
-          } else {
-            return ($scope.state.error = true)
-          }
-        })
-    })
-  }))
+  $modalInstance.opened.then(() =>
+    $scope.$applyAsync(() => $scope.$broadcast('open'))
+  )
+
+  return ($scope.addLabelModalFormSubmit = function() {
+    $scope.state.inflight = true
+    return ide.historyManager
+      .labelCurrentVersion($scope.inputs.labelName)
+      .then(function(response) {
+        $scope.state.inflight = false
+        return $modalInstance.close()
+      })
+      .catch(function(response) {
+        const { data, status } = response
+        $scope.state.inflight = false
+        if (status === 400) {
+          return ($scope.state.error = { message: data })
+        } else {
+          return ($scope.state.error = true)
+        }
+      })
+  })
+})

+ 32 - 31
services/web/frontend/js/ide/history/controllers/HistoryV2DeleteLabelModalController.js

@@ -9,35 +9,36 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('HistoryV2DeleteLabelModalController', function(
-    $scope,
-    $modalInstance,
-    ide,
-    labelDetails
-  ) {
-    $scope.labelDetails = labelDetails
-    $scope.state = {
-      inflight: false,
-      error: false
-    }
+import App from '../../../base'
 
-    return ($scope.deleteLabel = function() {
-      $scope.state.inflight = true
-      return ide.historyManager
-        .deleteLabel(labelDetails)
-        .then(function(response) {
-          $scope.state.inflight = false
-          return $modalInstance.close()
-        })
-        .catch(function(response) {
-          const { data, status } = response
-          $scope.state.inflight = false
-          if (status === 400) {
-            return ($scope.state.error = { message: data })
-          } else {
-            return ($scope.state.error = true)
-          }
-        })
-    })
-  }))
+export default App.controller('HistoryV2DeleteLabelModalController', function(
+  $scope,
+  $modalInstance,
+  ide,
+  labelDetails
+) {
+  $scope.labelDetails = labelDetails
+  $scope.state = {
+    inflight: false,
+    error: false
+  }
+
+  return ($scope.deleteLabel = function() {
+    $scope.state.inflight = true
+    return ide.historyManager
+      .deleteLabel(labelDetails)
+      .then(function(response) {
+        $scope.state.inflight = false
+        return $modalInstance.close()
+      })
+      .catch(function(response) {
+        const { data, status } = response
+        $scope.state.inflight = false
+        if (status === 400) {
+          return ($scope.state.error = { message: data })
+        } else {
+          return ($scope.state.error = true)
+        }
+      })
+  })
+})

+ 10 - 6
services/web/frontend/js/ide/history/controllers/HistoryV2FileTreeController.js

@@ -10,9 +10,13 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller('HistoryV2FileTreeController', function($scope, ide) {
-    $scope.handleFileSelection = file => {
-      ide.historyManager.selectFile(file)
-    }
-  }))
+import App from '../../../base'
+
+export default App.controller('HistoryV2FileTreeController', function(
+  $scope,
+  ide
+) {
+  $scope.handleFileSelection = file => {
+    ide.historyManager.selectFile(file)
+  }
+})

+ 35 - 33
services/web/frontend/js/ide/history/controllers/HistoryV2ListController.js

@@ -10,44 +10,46 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base', '../util/displayNameForUser'], (
-  App,
-  displayNameForUser
-) =>
-  App.controller('HistoryV2ListController', function($scope, $modal, ide) {
-    $scope.hoveringOverListSelectors = false
-    $scope.listConfig = { showOnlyLabelled: false }
+import App from '../../../base'
 
-    $scope.projectUsers = []
+export default App.controller('HistoryV2ListController', function(
+  $scope,
+  $modal,
+  ide
+) {
+  $scope.hoveringOverListSelectors = false
+  $scope.listConfig = { showOnlyLabelled: false }
 
-    $scope.$watch('project.members', function(newVal) {
-      if (newVal != null) {
-        return ($scope.projectUsers = newVal.concat($scope.project.owner))
-      }
-    })
+  $scope.projectUsers = []
 
-    $scope.loadMore = () => {
-      return ide.historyManager.fetchNextBatchOfUpdates()
+  $scope.$watch('project.members', function(newVal) {
+    if (newVal != null) {
+      return ($scope.projectUsers = newVal.concat($scope.project.owner))
     }
+  })
+
+  $scope.loadMore = () => {
+    return ide.historyManager.fetchNextBatchOfUpdates()
+  }
 
-    $scope.handleVersionSelect = version =>
-      $scope.$applyAsync(() =>
-        ide.historyManager.selectVersionForPointInTime(version)
-      )
+  $scope.handleVersionSelect = version =>
+    $scope.$applyAsync(() =>
+      ide.historyManager.selectVersionForPointInTime(version)
+    )
 
-    $scope.handleRangeSelect = (selectedToV, selectedFromV) =>
-      $scope.$applyAsync(() =>
-        ide.historyManager.selectVersionsForCompare(selectedToV, selectedFromV)
-      )
+  $scope.handleRangeSelect = (selectedToV, selectedFromV) =>
+    $scope.$applyAsync(() =>
+      ide.historyManager.selectVersionsForCompare(selectedToV, selectedFromV)
+    )
 
-    return ($scope.handleLabelDelete = labelDetails =>
-      $modal.open({
-        templateUrl: 'historyV2DeleteLabelModalTemplate',
-        controller: 'HistoryV2DeleteLabelModalController',
-        resolve: {
-          labelDetails() {
-            return labelDetails
-          }
+  return ($scope.handleLabelDelete = labelDetails =>
+    $modal.open({
+      templateUrl: 'historyV2DeleteLabelModalTemplate',
+      controller: 'HistoryV2DeleteLabelModalController',
+      resolve: {
+        labelDetails() {
+          return labelDetails
         }
-      }))
-  }))
+      }
+    }))
+})

+ 95 - 94
services/web/frontend/js/ide/history/controllers/HistoryV2ToolbarController.js

@@ -10,115 +10,116 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.controller(
-    'HistoryV2ToolbarController',
-    ($scope, $modal, ide, eventTracking, waitFor) => {
-      let openEntity
+import App from '../../../base'
 
-      $scope.currentUpdate = null
-      $scope.currentLabel = null
+export default App.controller(
+  'HistoryV2ToolbarController',
+  ($scope, $modal, ide, eventTracking, waitFor) => {
+    let openEntity
 
-      $scope.restoreState = {
-        inflight: false,
-        error: false
-      }
+    $scope.currentUpdate = null
+    $scope.currentLabel = null
 
-      $scope.toolbarUIConfig = {
-        showOnlyLabels: false
-      }
+    $scope.restoreState = {
+      inflight: false,
+      error: false
+    }
 
-      let _deregistershowOnlyLabelsWatcher = $scope.$watch(
-        'history.showOnlyLabels',
-        showOnlyLabels => {
-          if (showOnlyLabels != null) {
-            $scope.toolbarUIConfig.showOnlyLabels = showOnlyLabels
-            _deregistershowOnlyLabelsWatcher()
-          }
-        }
-      )
+    $scope.toolbarUIConfig = {
+      showOnlyLabels: false
+    }
 
-      $scope.$watch('toolbarUIConfig.showOnlyLabels', (newVal, oldVal) => {
-        if (newVal != null && newVal !== oldVal) {
-          if (newVal) {
-            ide.historyManager.showOnlyLabels()
-          } else {
-            ide.historyManager.showAllUpdates()
-          }
+    let _deregistershowOnlyLabelsWatcher = $scope.$watch(
+      'history.showOnlyLabels',
+      showOnlyLabels => {
+        if (showOnlyLabels != null) {
+          $scope.toolbarUIConfig.showOnlyLabels = showOnlyLabels
+          _deregistershowOnlyLabelsWatcher()
         }
-      })
+      }
+    )
 
-      $scope.$watch('history.viewMode', (newVal, oldVal) => {
-        if (newVal != null && newVal !== oldVal) {
-          $scope.currentUpdate = ide.historyManager.getUpdateForVersion(newVal)
+    $scope.$watch('toolbarUIConfig.showOnlyLabels', (newVal, oldVal) => {
+      if (newVal != null && newVal !== oldVal) {
+        if (newVal) {
+          ide.historyManager.showOnlyLabels()
+        } else {
+          ide.historyManager.showAllUpdates()
         }
-      })
+      }
+    })
 
-      $scope.$watch('history.selection.range.toV', (newVal, oldVal) => {
-        if (
-          newVal != null &&
-          newVal !== oldVal &&
-          $scope.history.viewMode === $scope.HistoryViewModes.POINT_IN_TIME
-        ) {
-          $scope.currentUpdate = ide.historyManager.getUpdateForVersion(newVal)
-        }
-      })
+    $scope.$watch('history.viewMode', (newVal, oldVal) => {
+      if (newVal != null && newVal !== oldVal) {
+        $scope.currentUpdate = ide.historyManager.getUpdateForVersion(newVal)
+      }
+    })
 
-      $scope.toggleHistoryViewMode = () => {
-        ide.historyManager.toggleHistoryViewMode()
+    $scope.$watch('history.selection.range.toV', (newVal, oldVal) => {
+      if (
+        newVal != null &&
+        newVal !== oldVal &&
+        $scope.history.viewMode === $scope.HistoryViewModes.POINT_IN_TIME
+      ) {
+        $scope.currentUpdate = ide.historyManager.getUpdateForVersion(newVal)
       }
+    })
 
-      $scope.restoreDeletedFile = function() {
-        const { pathname, deletedAtV } = $scope.history.selection.file
-        if (pathname == null || deletedAtV == null) {
-          return
-        }
+    $scope.toggleHistoryViewMode = () => {
+      ide.historyManager.toggleHistoryViewMode()
+    }
 
-        eventTracking.sendMB('history-v2-restore-deleted')
-        $scope.restoreState.inflight = true
-        return ide.historyManager
-          .restoreFile(deletedAtV, pathname)
-          .then(function(response) {
-            const { data } = response
-            return openEntity(data)
-          })
-          .catch(() =>
-            ide.showGenericMessageModal(
-              'Sorry, something went wrong with the restore'
-            )
-          )
-          .finally(() => ($scope.restoreState.inflight = false))
+    $scope.restoreDeletedFile = function() {
+      const { pathname, deletedAtV } = $scope.history.selection.file
+      if (pathname == null || deletedAtV == null) {
+        return
       }
 
-      $scope.showAddLabelDialog = () => {
-        $modal.open({
-          templateUrl: 'historyV2AddLabelModalTemplate',
-          controller: 'HistoryV2AddLabelModalController',
-          resolve: {
-            update() {
-              return $scope.history.selection.update
-            }
-          }
+      eventTracking.sendMB('history-v2-restore-deleted')
+      $scope.restoreState.inflight = true
+      return ide.historyManager
+        .restoreFile(deletedAtV, pathname)
+        .then(function(response) {
+          const { data } = response
+          return openEntity(data)
         })
-      }
+        .catch(() =>
+          ide.showGenericMessageModal(
+            'Sorry, something went wrong with the restore'
+          )
+        )
+        .finally(() => ($scope.restoreState.inflight = false))
+    }
 
-      openEntity = function(data) {
-        const { id, type } = data
-        return waitFor(() => ide.fileTreeManager.findEntityById(id), 3000)
-          .then(function(entity) {
-            if (type === 'doc') {
-              ide.editorManager.openDoc(entity)
-              this.ide.$timeout(() => {
-                this.$scope.$broadcast('history:toggle')
-              }, 0)
-            } else if (type === 'file') {
-              ide.binaryFilesManager.openFile(entity)
-              this.ide.$timeout(() => {
-                this.$scope.$broadcast('history:toggle')
-              }, 0)
-            }
-          })
-          .catch(err => console.warn(err))
-      }
+    $scope.showAddLabelDialog = () => {
+      $modal.open({
+        templateUrl: 'historyV2AddLabelModalTemplate',
+        controller: 'HistoryV2AddLabelModalController',
+        resolve: {
+          update() {
+            return $scope.history.selection.update
+          }
+        }
+      })
+    }
+
+    openEntity = function(data) {
+      const { id, type } = data
+      return waitFor(() => ide.fileTreeManager.findEntityById(id), 3000)
+        .then(function(entity) {
+          if (type === 'doc') {
+            ide.editorManager.openDoc(entity)
+            this.ide.$timeout(() => {
+              this.$scope.$broadcast('history:toggle')
+            }, 0)
+          } else if (type === 'file') {
+            ide.binaryFilesManager.openFile(entity)
+            this.ide.$timeout(() => {
+              this.$scope.$broadcast('history:toggle')
+            }, 0)
+          }
+        })
+        .catch(err => console.warn(err))
     }
-  ))
+  }
+)

+ 33 - 32
services/web/frontend/js/ide/history/directives/historyDraggableBoundary.js

@@ -1,32 +1,33 @@
-define(['../../../base'], App =>
-  App.directive('historyDraggableBoundary', () => ({
-    scope: {
-      historyDraggableBoundary: '@',
-      historyDraggableBoundaryOnDragStart: '&',
-      historyDraggableBoundaryOnDragStop: '&'
-    },
-    restrict: 'A',
-    link(scope, element, attrs) {
-      element.data('selectionBoundary', {
-        boundary: scope.historyDraggableBoundary
-      })
-      element.draggable({
-        axis: 'y',
-        opacity: false,
-        helper: 'clone',
-        revert: true,
-        scroll: true,
-        cursor: 'row-resize',
-        start(e, ui) {
-          ui.helper.data('wasProperlyDropped', false)
-          scope.historyDraggableBoundaryOnDragStart()
-        },
-        stop(e, ui) {
-          scope.historyDraggableBoundaryOnDragStop({
-            isValidDrop: ui.helper.data('wasProperlyDropped'),
-            boundary: scope.historyDraggableBoundary
-          })
-        }
-      })
-    }
-  })))
+import App from '../../../base'
+
+export default App.directive('historyDraggableBoundary', () => ({
+  scope: {
+    historyDraggableBoundary: '@',
+    historyDraggableBoundaryOnDragStart: '&',
+    historyDraggableBoundaryOnDragStop: '&'
+  },
+  restrict: 'A',
+  link(scope, element, attrs) {
+    element.data('selectionBoundary', {
+      boundary: scope.historyDraggableBoundary
+    })
+    element.draggable({
+      axis: 'y',
+      opacity: false,
+      helper: 'clone',
+      revert: true,
+      scroll: true,
+      cursor: 'row-resize',
+      start(e, ui) {
+        ui.helper.data('wasProperlyDropped', false)
+        scope.historyDraggableBoundaryOnDragStart()
+      },
+      stop(e, ui) {
+        scope.historyDraggableBoundaryOnDragStop({
+          isValidDrop: ui.helper.data('wasProperlyDropped'),
+          boundary: scope.historyDraggableBoundary
+        })
+      }
+    })
+  }
+}))

+ 24 - 25
services/web/frontend/js/ide/history/directives/historyDroppableArea.js

@@ -1,25 +1,24 @@
-define(['../../../base'], App =>
-  App.directive('historyDroppableArea', () => ({
-    scope: {
-      historyDroppableAreaOnDrop: '&',
-      historyDroppableAreaOnOver: '&',
-      historyDroppableAreaOnOut: '&'
-    },
-    restrict: 'A',
-    link(scope, element, attrs) {
-      element.droppable({
-        accept: e => '.history-entry-toV-handle, .history-entry-fromV-handle',
-        drop: (e, ui) => {
-          const draggedBoundary = ui.draggable.data('selectionBoundary')
-            .boundary
-          ui.helper.data('wasProperlyDropped', true)
-          scope.historyDroppableAreaOnDrop({ boundary: draggedBoundary })
-        },
-        over: (e, ui) => {
-          const draggedBoundary = ui.draggable.data('selectionBoundary')
-            .boundary
-          scope.historyDroppableAreaOnOver({ boundary: draggedBoundary })
-        }
-      })
-    }
-  })))
+import App from '../../../base'
+
+export default App.directive('historyDroppableArea', () => ({
+  scope: {
+    historyDroppableAreaOnDrop: '&',
+    historyDroppableAreaOnOver: '&',
+    historyDroppableAreaOnOut: '&'
+  },
+  restrict: 'A',
+  link(scope, element, attrs) {
+    element.droppable({
+      accept: e => '.history-entry-toV-handle, .history-entry-fromV-handle',
+      drop: (e, ui) => {
+        const draggedBoundary = ui.draggable.data('selectionBoundary').boundary
+        ui.helper.data('wasProperlyDropped', true)
+        scope.historyDroppableAreaOnDrop({ boundary: draggedBoundary })
+      },
+      over: (e, ui) => {
+        const draggedBoundary = ui.draggable.data('selectionBoundary').boundary
+        scope.historyDroppableAreaOnOver({ boundary: draggedBoundary })
+      }
+    })
+  }
+}))

+ 34 - 33
services/web/frontend/js/ide/history/directives/infiniteScroll.js

@@ -9,44 +9,45 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define(['../../../base'], App =>
-  App.directive('infiniteScroll', () => ({
-    link(scope, element, attrs, ctrl) {
-      const innerElement = element.find('.infinite-scroll-inner')
-      element.css({ 'overflow-y': 'auto' })
-
-      const atEndOfListView = function() {
-        if (attrs.infiniteScrollUpwards != null) {
-          return atTopOfListView()
-        } else {
-          return atBottomOfListView()
-        }
+import App from '../../../base'
+
+export default App.directive('infiniteScroll', () => ({
+  link(scope, element, attrs, ctrl) {
+    const innerElement = element.find('.infinite-scroll-inner')
+    element.css({ 'overflow-y': 'auto' })
+
+    const atEndOfListView = function() {
+      if (attrs.infiniteScrollUpwards != null) {
+        return atTopOfListView()
+      } else {
+        return atBottomOfListView()
       }
+    }
 
-      var atTopOfListView = () => element.scrollTop() < 30
+    var atTopOfListView = () => element.scrollTop() < 30
 
-      var atBottomOfListView = () =>
-        element.scrollTop() + element.height() >= innerElement.height() - 30
+    var atBottomOfListView = () =>
+      element.scrollTop() + element.height() >= innerElement.height() - 30
 
-      const listShorterThanContainer = () =>
-        element.height() > innerElement.height()
+    const listShorterThanContainer = () =>
+      element.height() > innerElement.height()
 
-      var loadUntilFull = function() {
-        if (
-          (listShorterThanContainer() || atEndOfListView()) &&
-          !scope.$eval(attrs.infiniteScrollDisabled)
-        ) {
-          const promise = scope.$eval(attrs.infiniteScroll)
-          return promise.then(() => setTimeout(() => loadUntilFull(), 0))
-        }
+    var loadUntilFull = function() {
+      if (
+        (listShorterThanContainer() || atEndOfListView()) &&
+        !scope.$eval(attrs.infiniteScrollDisabled)
+      ) {
+        const promise = scope.$eval(attrs.infiniteScroll)
+        return promise.then(() => setTimeout(() => loadUntilFull(), 0))
       }
+    }
 
-      element.on('scroll', event => loadUntilFull())
+    element.on('scroll', event => loadUntilFull())
 
-      return scope.$watch(attrs.infiniteScrollInitialize, function(value) {
-        if (value) {
-          return loadUntilFull()
-        }
-      })
-    }
-  })))
+    return scope.$watch(attrs.infiniteScrollInitialize, function(value) {
+      if (value) {
+        return loadUntilFull()
+      }
+    })
+  }
+}))

+ 5 - 6
services/web/frontend/js/ide/history/util/HistoryViewModes.js

@@ -9,10 +9,9 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  let HistoryViewModes
-  return (HistoryViewModes = {
-    POINT_IN_TIME: 'point_in_time',
-    COMPARE: 'compare'
-  })
+let HistoryViewModes
+
+export default (HistoryViewModes = {
+  POINT_IN_TIME: 'point_in_time',
+  COMPARE: 'compare'
 })

+ 23 - 24
services/web/frontend/js/ide/history/util/displayNameForUser.js

@@ -10,28 +10,27 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  let displayNameForUser
-  return (displayNameForUser = function(user) {
-    if (user == null) {
-      return 'Anonymous'
-    }
-    if (user.id === window.user.id) {
-      return 'you'
-    }
-    if (user.name != null) {
-      return user.name
-    }
-    let name = [user.first_name, user.last_name]
-      .filter(n => n != null)
-      .join(' ')
-      .trim()
-    if (name === '') {
-      name = user.email.split('@')[0]
-    }
-    if (name == null || name === '') {
-      return '?'
-    }
-    return name
-  })
+let displayNameForUser
+
+export default (displayNameForUser = function(user) {
+  if (user == null) {
+    return 'Anonymous'
+  }
+  if (user.id === window.user.id) {
+    return 'you'
+  }
+  if (user.name != null) {
+    return user.name
+  }
+  let name = [user.first_name, user.last_name]
+    .filter(n => n != null)
+    .join(' ')
+    .trim()
+  if (name === '') {
+    name = user.email.split('@')[0]
+  }
+  if (name == null || name === '') {
+    return '?'
+  }
+  return name
 })

+ 7 - 13
services/web/frontend/js/ide/hotkeys/BackspaceHighjack.js

@@ -8,19 +8,13 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-define([], function() {
-  const rx = /INPUT|SELECT|TEXTAREA/i
+const rx = /INPUT|SELECT|TEXTAREA/i
 
-  return $(document).bind('keydown keypress', function(e) {
-    if (e.which === 8) {
-      // 8 == backspace
-      if (
-        !rx.test(e.target.tagName) ||
-        e.target.disabled ||
-        e.target.readOnly
-      ) {
-        return e.preventDefault()
-      }
+export default $(document).bind('keydown keypress', function(e) {
+  if (e.which === 8) {
+    // 8 == backspace
+    if (!rx.test(e.target.tagName) || e.target.disabled || e.target.readOnly) {
+      return e.preventDefault()
     }
-  })
+  }
 })

Some files were not shown because too many files changed in this diff