瀏覽代碼

Add missing files

James Allen 12 年之前
父節點
當前提交
8ed03694c3
共有 100 個文件被更改,包括 10396 次插入 和 0 次删除
  1. 12 0
      services/web/public/coffee/base.coffee
  2. 63 0
      services/web/public/coffee/directives/asyncForm.coffee
  3. 15 0
      services/web/public/coffee/directives/equals.coffee
  4. 66 0
      services/web/public/coffee/directives/fineUpload.coffee
  5. 67 0
      services/web/public/coffee/directives/focus.coffee
  6. 10 0
      services/web/public/coffee/directives/onEnter.coffee
  7. 67 0
      services/web/public/coffee/directives/selectAll.coffee
  8. 18 0
      services/web/public/coffee/directives/stopPropagation.coffee
  9. 18 0
      services/web/public/coffee/filters/formatDate.coffee
  10. 12 0
      services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee
  11. 7 0
      services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee
  12. 7 0
      services/web/public/coffee/ide/chat/controllers/ChatButtonController.coffee
  13. 64 0
      services/web/public/coffee/ide/chat/controllers/ChatController.coffee
  14. 12 0
      services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee
  15. 5 0
      services/web/public/coffee/ide/chat/index.coffee
  16. 113 0
      services/web/public/coffee/ide/connection/ConnectionManager.coffee
  17. 102 0
      services/web/public/coffee/ide/directives/layout.coffee
  18. 253 0
      services/web/public/coffee/ide/editor/Document.coffee
  19. 103 0
      services/web/public/coffee/ide/editor/EditorManager.coffee
  20. 123 0
      services/web/public/coffee/ide/editor/ShareJsDoc.coffee
  21. 33 0
      services/web/public/coffee/ide/editor/controllers/SavingNotificationController.coffee
  22. 187 0
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  23. 95 0
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee
  24. 100 0
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee
  25. 126 0
      services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee
  26. 52 0
      services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee
  27. 228 0
      services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee
  28. 150 0
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee
  29. 191 0
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee
  30. 82 0
      services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellingMenuView.coffee
  31. 374 0
      services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee
  32. 3 0
      services/web/public/coffee/ide/editor/sharejs/header.coffee
  33. 128 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/ace.coffee
  34. 119 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/ace.js
  35. 94 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/cm.coffee
  36. 167 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/connection.coffee
  37. 327 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee
  38. 332 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.js
  39. 73 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/index.coffee
  40. 46 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/microevent.coffee
  41. 85 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/microevent.js
  42. 69 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/textarea.coffee
  43. 13 0
      services/web/public/coffee/ide/editor/sharejs/vendor/client/web-prelude.coffee
  44. 5 0
      services/web/public/coffee/ide/editor/sharejs/vendor/index.coffee
  45. 333 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/browserchannel.coffee
  46. 149 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/db/couchdb.coffee
  47. 28 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/db/index.coffee
  48. 198 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/db/pg.coffee
  49. 140 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/db/redis.coffee
  50. 57 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/index.coffee
  51. 603 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/model.coffee
  52. 148 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/rest.coffee
  53. 336 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/socketio.coffee
  54. 42 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/syncqueue.coffee
  55. 146 0
      services/web/public/coffee/ide/editor/sharejs/vendor/server/useragent.coffee
  56. 48 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/README.md
  57. 22 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/count.coffee
  58. 65 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/helpers.coffee
  59. 15 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/index.coffee
  60. 180 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/json-api.coffee
  61. 441 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/json.coffee
  62. 38 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/simple.coffee
  63. 32 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee
  64. 58 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.js
  65. 43 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-composable-api.coffee
  66. 261 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-composable.coffee
  67. 89 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-tp2-api.coffee
  68. 322 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text-tp2.coffee
  69. 209 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee
  70. 239 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/text.js
  71. 11 0
      services/web/public/coffee/ide/editor/sharejs/vendor/types/web-prelude.coffee
  72. 281 0
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  73. 113 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee
  74. 49 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee
  75. 51 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeFolderController.coffee
  76. 13 0
      services/web/public/coffee/ide/file-tree/controllers/FileTreeRootFolderController.coffee
  77. 14 0
      services/web/public/coffee/ide/file-tree/directives/draggable.coffee
  78. 17 0
      services/web/public/coffee/ide/file-tree/directives/droppable.coffee
  79. 23 0
      services/web/public/coffee/ide/file-tree/directives/fileEntity.coffee
  80. 71 0
      services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee
  81. 20 0
      services/web/public/coffee/ide/pdf/PdfManager.coffee
  82. 277 0
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  83. 196 0
      services/web/public/coffee/ide/pdf/directives/pdfJs.coffee
  84. 19 0
      services/web/public/coffee/ide/permissions/PermissionsManager.coffee
  85. 37 0
      services/web/public/coffee/ide/services/ide.coffee
  86. 26 0
      services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee
  87. 51 0
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  88. 7 0
      services/web/public/coffee/ide/settings/index.coffee
  89. 14 0
      services/web/public/coffee/ide/settings/services/settings.coffee
  90. 11 0
      services/web/public/coffee/ide/share/controllers/ShareController.coffee
  91. 104 0
      services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee
  92. 5 0
      services/web/public/coffee/ide/share/index.coffee
  93. 30 0
      services/web/public/coffee/ide/share/services/projectMembers.coffee
  94. 256 0
      services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee
  95. 9 0
      services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee
  96. 109 0
      services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee
  97. 45 0
      services/web/public/coffee/ide/track-changes/directives/infiniteScroll.coffee
  98. 9 0
      services/web/public/coffee/libs.coffee
  99. 16 0
      services/web/public/coffee/main.coffee
  100. 54 0
      services/web/public/coffee/main/account-settings.coffee

+ 12 - 0
services/web/public/coffee/base.coffee

@@ -0,0 +1,12 @@
+define [
+	"libs"
+	"modules/recursionHelper"
+], () ->
+	App = angular.module("SharelatexApp", [
+		"ui.bootstrap"
+		"autocomplete"
+		"RecursionHelper"
+		"ng-context-menu"
+	])
+
+	return App

+ 63 - 0
services/web/public/coffee/directives/asyncForm.coffee

@@ -0,0 +1,63 @@
+define [
+	"base"
+], (App) ->
+	App.directive "asyncForm", ($http) ->
+		return {
+			link: (scope, element, attrs) ->
+				formName = attrs.asyncForm
+
+				scope[attrs.name].response = response = {}
+
+				element.on "submit", (e) ->
+					e.preventDefault()
+
+					formData = {}
+					for data in element.serializeArray()
+						formData[data.name] = data.value
+
+					$http
+						.post(element.attr('action'), formData)
+						.success (data, status, headers, config) ->
+							response.success = true
+							response.error = false
+
+							if data.redir?
+								ga('send', 'event', formName, 'success')
+								window.location = data.redir
+							else if data.message?
+								response.message = data.message
+
+								if data.message.type == "error"
+									response.success = false
+									response.error = true
+									ga('send', 'event', formName, 'failure', data.message)
+								else
+									ga('send', 'event', formName, 'success')
+									
+						.error (data, status, headers, config) ->
+							response.success = false
+							response.error = true
+							response.message =
+								text: data.message or "Something went wrong talking to the server :(. Please try again."
+								type: 'error'
+							ga('send', 'event', formName, 'failure', data.message)
+		}
+
+	App.directive "formMessages", () ->
+		return {
+			restrict: "E"
+			template: """
+				<div class="alert" ng-class="{
+					'alert-danger': form.response.message.type == 'error',
+					'alert-success': form.response.message.type != 'error'
+				}" ng-show="!!form.response.message">
+					{{form.response.message.text}}
+				</div>
+				<div ng-transclude></div>
+			"""
+			transclude: true
+			scope: {
+				form: "=for"
+			}
+
+		}

+ 15 - 0
services/web/public/coffee/directives/equals.coffee

@@ -0,0 +1,15 @@
+define [
+	"base"
+], (App) ->
+
+	App.directive 'equals', () ->
+		return {
+			require: "ngModel",
+			link: (scope, element, attrs, ngModel) ->
+				scope.$watch attrs.ngModel, () -> validate()
+				attrs.$observe 'equals', () -> validate()
+
+				validate = () ->
+					equal = (attrs.equals == ngModel.$viewValue)
+					ngModel.$setValidity('areEqual', equal)
+		}

+ 66 - 0
services/web/public/coffee/directives/fineUpload.coffee

@@ -0,0 +1,66 @@
+define [
+	"base"
+], (App) ->
+	App.directive 'fineUpload', ($timeout) ->
+		return {
+			scope: {
+				multiple: "="
+				endpoint: "@"
+				waitingForResponseText: "@"
+				failedUploadText: "@"
+				uploadButtonText: "@"
+				dragAreaText: "@"
+				hintText: "@"
+				allowedExtensions: "="
+				onCompleteCallback: "="
+				onUploadCallback: "="
+				params: "="
+			}
+			link: (scope, element, attrs) ->
+				multiple = scope.multiple or false
+				endpoint = scope.endpoint
+				if scope.allowedExtensions?
+					validation = 
+						allowedExtensions: scope.allowedExtensions
+				else
+					validation = {}
+				text =
+					waitingForResponse: scope.waitingForResponseText or "Processing..."
+					failUpload: scope.failedUploadText or "Failed :("
+					uploadButton: scope.uploadButtonText or "Upload"
+				dragAreaText = scope.dragAreaText or "drag here"
+				hintText = scope.hintText or ""
+
+				onComplete = scope.onCompleteCallback or () ->
+				onUpload   = scope.onUploadCallback or () ->
+				params     = scope.params or {}
+				params._csrf = window.csrfToken
+
+				new qq.FineUploader
+					element: element[0]
+					multiple: multiple
+					disabledCancelForFormUploads: true
+					validation: validation
+					request:
+						endpoint: endpoint
+						forceMultipart: true
+						params: params
+						paramsInBody: false
+					callbacks:
+						onComplete: onComplete
+						onUpload:   onUpload
+					text: text
+					template: """
+						<div class="qq-uploader">
+							<div class="qq-upload-drop-area"><span>{dragZoneText}</span></div>
+							<div class="qq-upload-button btn btn-primary btn-lg">
+								<div>{uploadButtonText}</div>
+							</div>
+							<span class="or btn-lg"> or </span>
+							<span class="drag-here btn-lg">#{dragAreaText}</span>
+							<span class="qq-drop-processing"><span>{dropProcessingText}</span><span class="qq-drop-processing-spinner"></span></span>
+							<div class="small">#{hintText}</div>
+							<ul class="qq-upload-list"></ul>
+						</div>
+					"""
+		}

+ 67 - 0
services/web/public/coffee/directives/focus.coffee

@@ -0,0 +1,67 @@
+define [
+	"base"
+], (App) ->
+	App.directive "focusWhen", ($timeout) ->
+		return {
+			restrict: "A"
+			link: (scope, element, attr) ->
+				scope.$watch attr.focusWhen, (value) ->
+					if value
+						$timeout ->
+							element.focus()
+		}
+
+	App.directive 'focusOn', ($timeout) ->
+		return {
+			restrict: 'A'
+			link: (scope, element, attrs) ->
+				scope.$on attrs.focusOn, () ->
+					element.focus()
+		}
+
+	App.directive "selectWhen", ($timeout) ->
+		return {
+			restrict: "A"
+			link: (scope, element, attr) ->
+				scope.$watch attr.selectWhen, (value) ->
+					if value
+						$timeout ->
+							element.select()
+		}
+
+	App.directive 'selectOn', ($timeout) ->
+		return {
+			restrict: 'A'
+			link: (scope, element, attrs) ->
+				scope.$on attrs.selectOn, () ->
+					element.select()
+		}
+
+	App.directive "selectNameWhen", ($timeout) ->
+		return {
+			restrict: 'A'
+			link: (scope, element, attrs) ->
+				scope.$watch attrs.selectNameWhen, (value) ->
+					if value
+						$timeout () ->
+							selectName(element)
+		}
+
+	App.directive "selectNameOn", () ->
+		return {
+			restrict: 'A'
+			link: (scope, element, attrs) ->
+				scope.$on attrs.selectNameOn, () ->
+					selectName(element)
+		}
+
+	selectName = (element) ->
+		# Select up to last '.'. I.e. everything
+		# except the file extension
+		element.focus()
+		name = element.val()
+		if element[0].setSelectionRange?
+			selectionEnd = name.lastIndexOf(".")
+			if selectionEnd == -1
+				selectionEnd = name.length
+			element[0].setSelectionRange(0, selectionEnd)

+ 10 - 0
services/web/public/coffee/directives/onEnter.coffee

@@ -0,0 +1,10 @@
+define [
+	"base"
+], (App) ->
+	App.directive 'onEnter', () ->
+		return (scope, element, attrs) ->
+			element.bind "keydown keypress", (event) ->
+				if event.which == 13
+					scope.$apply () ->
+						scope.$eval(attrs.onEnter, event: event)
+					event.preventDefault()

+ 67 - 0
services/web/public/coffee/directives/selectAll.coffee

@@ -0,0 +1,67 @@
+define [
+	"base"
+], (App) ->
+	App.directive "selectAllList", () ->
+		return {
+			controller: ["$scope", ($scope) ->
+				# Selecting or deselecting all should apply to all projects
+				selectAll = () ->
+					$scope.$broadcast "select-all:select"
+
+				deselectAll = () ->
+					$scope.$broadcast "select-all:deselect"
+
+				clearSelectAllState = () ->
+					$scope.$broadcast "select-all:clear"
+
+				return {
+					clearSelectAllState: clearSelectAllState
+					selectAll: selectAll
+					deselectAll: deselectAll
+				}
+			]
+			link: (scope, element, attrs) ->
+
+
+		}
+
+	App.directive "selectAll", () ->
+		return {
+			require: "^selectAllList"
+			link: (scope, element, attrs, selectAllListController) ->
+				scope.$on "select-all:clear", () ->
+					element.prop("checked", false)
+
+				element.change () ->
+					if element.is(":checked")
+						selectAllListController.selectAll()
+					else
+						selectAllListController.deselectAll()
+					return true
+		}
+
+	App.directive "selectIndividual", () ->
+		return {
+			require: "^selectAllList"
+			scope: {
+				ngModel: "="
+			}
+			link: (scope, element, attrs, selectAllListController) ->
+				ignoreChanges = false
+
+				scope.$watch "ngModel", (value) ->
+					if value? and !ignoreChanges
+						selectAllListController.clearSelectAllState()
+
+				scope.$on "select-all:select", () ->
+					ignoreChanges = true
+					scope.$apply () ->
+						scope.ngModel = true
+					ignoreChanges = false
+
+				scope.$on "select-all:deselect", () ->
+					ignoreChanges = true
+					scope.$apply () ->
+						scope.ngModel = false
+					ignoreChanges = false
+		}

+ 18 - 0
services/web/public/coffee/directives/stopPropagation.coffee

@@ -0,0 +1,18 @@
+define [
+	"base"
+], (App) ->
+	App.directive "stopPropagation", ($http) ->
+		return {
+			restrict: "A",
+			link: (scope, element, attrs) ->
+				element.bind attrs.stopPropagation, (e) ->
+					e.stopPropagation()
+		}
+
+	App.directive "preventDefault", ($http) ->
+		return {
+			restrict: "A",
+			link: (scope, element, attrs) ->
+				element.bind attrs.preventDefault, (e) ->
+					e.preventDefault()
+		}

+ 18 - 0
services/web/public/coffee/filters/formatDate.coffee

@@ -0,0 +1,18 @@
+define [
+	"base"
+], (App) ->
+	moment.lang "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", () ->
+		(date, format = "Do MMM YYYY, h:mm a") ->
+			moment(date).format(format)
+
+	App.filter "relativeDate", () ->
+		(date) ->
+			moment(date).calendar()

+ 12 - 0
services/web/public/coffee/ide/binary-files/BinaryFilesManager.coffee

@@ -0,0 +1,12 @@
+define [
+	"ide/binary-files/controllers/BinaryFileController"
+], () ->
+	class BinaryFilesManager
+		constructor: (@ide, @$scope) ->
+			@$scope.$on "entity:selected", (event, entity) =>
+				if (@$scope.ui.view != "track-changes" and entity.type == "file")
+					@openFile(entity)
+
+		openFile: (file) ->
+			@$scope.ui.view = "file"
+			@$scope.openFile = file

+ 7 - 0
services/web/public/coffee/ide/binary-files/controllers/BinaryFileController.coffee

@@ -0,0 +1,7 @@
+define [
+	"base"
+], (App) ->
+	App.controller "BinaryFileController", ["$scope", ($scope) ->
+		$scope.extension = (file) ->
+			return file.name.split(".").pop()?.toLowerCase()
+	]

+ 7 - 0
services/web/public/coffee/ide/chat/controllers/ChatButtonController.coffee

@@ -0,0 +1,7 @@
+define [
+	"base"
+], (App) ->
+	App.controller "ChatButtonController", ["$scope", ($scope) ->
+		$scope.toggleChat = () ->
+			$scope.ui.chatOpen = !$scope.ui.chatOpen
+	]

+ 64 - 0
services/web/public/coffee/ide/chat/controllers/ChatController.coffee

@@ -0,0 +1,64 @@
+define [
+	"base"
+], (App) ->
+	App.controller "ChatController", ["$scope", ($scope) ->
+		james = {
+			id: $scope.user.id
+			first_name: "James"
+			last_name: "Allen"
+			email: "james@sharelatex.com"
+		}
+		james2 = {
+			id: "james2-id"
+			first_name: "James"
+			last_name: "Allen"
+			email: "jamesallen0108@gmail.com"
+		}
+		henry = {
+			id: "henry-id"
+			first_name: "Henry"
+			last_name: "Oswald"
+			email: "henry.oswald@sharelatex.com"
+		}
+		$scope.chat.messages = [
+			{ content: "Hello world", timestamp: Date.now() - 2000, user: james2 }
+			{ content: "Hello, this is the new chat", timestamp: Date.now() - 4000, user: james }
+			{ content: "Here are some longer messages to show what it looks like when I say a lot!", timestamp: Date.now() - 20000, user: henry }
+			{ content: "What about some maths? $x^2 = 1$?", timestamp: Date.now() - 22000, user: james2 }
+			{ content: "Nope, that doesn't work yet!", timestamp: Date.now() - 45000, user: henry }
+			{ content: "I'm running out of things to say.", timestamp: Date.now() - 56000, user: henry }
+			{ content: "Yep, me too", timestamp: Date.now() - 100000, user: james }
+			{ content: "Hmm, looks like we've had this conversation backwards", timestamp: Date.now() - 120000, user: james }
+			{ content: "Hello world", timestamp: Date.now() - 202000, user: james2 }
+			{ content: "Hello, this is the new chat", timestamp: Date.now() - 204000, user: james }
+			{ content: "Here are some longer messages to show what it looks like when I say a lot!", timestamp: Date.now() - 2020000, user: henry }
+			{ content: "What about some maths? $x^2 = 1$?", timestamp: Date.now() - 12022000, user: james2 }
+			{ content: "Nope, that doesn't work yet!", timestamp: Date.now() - 12045000, user: henry }
+			{ content: "I'm running out of things to say.", timestamp: Date.now() - 22056000, user: henry }
+			{ content: "Yep, me too", timestamp: Date.now() - 220100000, user: james }
+			{ content: "Hmm, looks like we've had this conversation backwards", timestamp: Date.now() - 520120000, user: james }
+		].reverse()
+
+		$scope.$watch "chat.messages", (messages) ->
+			if messages?
+				$scope.chat.groupedMessages = groupMessages(messages)
+
+		TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 # 5 minutes
+		groupMessages = (messages) ->
+			previousMessage = null
+			groupedMessages = []
+			for message in messages
+				shouldGroup = previousMessage? and
+					previousMessage.user == message.user and
+					message.timestamp - previousMessage.timestamp < TIMESTAMP_GROUP_SIZE
+				if shouldGroup
+					previousMessage.timestamp = message.timestamp
+					previousMessage.contents.push message.content
+				else
+					groupedMessages.push(previousMessage = {
+						user: message.user
+						timestamp: message.timestamp
+						contents: [message.content]
+					})
+			return groupedMessages
+	]

+ 12 - 0
services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee

@@ -0,0 +1,12 @@
+define [
+	"base"
+], (App) ->
+	App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
+		$scope.gravatarUrl = (user) ->
+			email = user.email.trim().toLowerCase()
+			hash = CryptoJS.MD5(email).toString()
+			return "//www.gravatar.com/avatar/#{hash}?d=mm&s=50"
+
+		$scope.hue = (user) ->
+			ide.onlineUsersManager.getHueForUserId(user.id)
+	]

+ 5 - 0
services/web/public/coffee/ide/chat/index.coffee

@@ -0,0 +1,5 @@
+define [
+	"ide/chat/controllers/ChatButtonController"
+	"ide/chat/controllers/ChatController"
+	"ide/chat/controllers/ChatMessageController"
+], () ->

+ 113 - 0
services/web/public/coffee/ide/connection/ConnectionManager.coffee

@@ -0,0 +1,113 @@
+define [], () ->
+	class ConnectionManager
+		constructor: (@ide, @$scope) ->
+			@connected = false
+
+			@$scope.connection = 
+				reconnecting: false
+				# If we need to force everyone to reload the editor
+				forced_disconnect: false
+
+			@$scope.tryReconnectNow = () =>
+				@tryReconnect()
+
+			@ide.socket = io.connect null,
+				reconnect: false
+				"force new connection": true
+
+			@ide.socket.on "connect", () =>
+				@connected = true
+				@ide.pushEvent("connected")
+
+				@$scope.$apply () =>
+					@$scope.connection.reconnecting = false
+					if @$scope.state.loading
+						@$scope.state.load_progress = 80
+
+				setTimeout(() =>
+					@joinProject()
+				, 100)
+
+			@ide.socket.on 'disconnect', () =>
+				@connected = false
+				@ide.pushEvent("disconnected")
+
+				@$scope.$apply () =>
+					@$scope.connection.reconnecting = false
+
+				setTimeout(=>
+					ga('send', 'event', 'editor-interaction', 'disconnect')
+				, 2000)
+
+				if !$scope.connection.forced_disconnect
+					@startAutoReconnectCountdown()
+
+			@ide.socket.on 'forceDisconnect', (message) =>
+				@$scope.$apply () =>
+					@$scope.connection.forced_disconnect = true
+				@socket.disconnect()
+
+		joinProject: () ->
+			@ide.socket.emit 'joinProject', {
+				project_id: @ide.project_id
+			}, (err, project, permissionsLevel, protocolVersion) =>
+				if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
+					location.reload(true)
+
+				@$scope.$apply () =>
+					@$scope.protocolVersion = protocolVersion
+					@$scope.project = project
+					@$scope.permissionsLevel = permissionsLevel
+					@$scope.state.load_progress = 100
+					@$scope.state.loading = false
+					@$scope.$emit "project:joined"
+
+		reconnectImmediately: () ->
+			@disconnect()
+			@tryReconnect()
+
+		disconnect: () ->
+			@ide.socket.disconnect()
+
+		startAutoReconnectCountdown: () ->
+			lastUpdated = @ide.editorManager.lastUpdated()
+
+			twoMinutes = 2 * 60 * 1000
+			if lastUpdated? and new Date() - lastUpdated > twoMinutes
+				# between 1 minute and 3 minutes
+				countdown = 60 + Math.floor(Math.random() * 120)
+			else
+				countdown = 3 + Math.floor(Math.random() * 7)
+
+			@$scope.$apply () =>
+				@$scope.connection.reconnecting = false
+				@$scope.connection.reconnection_countdown = countdown
+
+			setTimeout(=>
+				if !@connected
+					@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
+			, 200)
+
+		cancelReconnect: () ->
+			clearTimeout @timeoutId if @timeoutId?
+					
+		decreaseCountdown: () ->
+			console.log "Decreasing countdown"
+			return if !@$scope.connection.reconnection_countdown?
+			@$scope.$apply () =>
+				@$scope.connection.reconnection_countdown--
+
+			if @$scope.connection.reconnection_countdown <= 0
+				@$scope.$apply () =>
+					@tryReconnect()
+			else
+				@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
+
+		tryReconnect: () ->
+			console.log "Trying reconnect"
+			@cancelReconnect()
+			@$scope.connection.reconnecting = true
+			delete @$scope.connection.reconnection_countdown
+			@ide.socket.socket.reconnect()
+			setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
+

+ 102 - 0
services/web/public/coffee/ide/directives/layout.coffee

@@ -0,0 +1,102 @@
+define [
+	"base"
+	"libs/jquery-layout"
+], (App) ->
+	App.directive "layout", ["$parse", ($parse) ->
+		return {
+			compile: () ->
+				pre: (scope, element, attrs) ->
+					name = attrs.layout
+
+					if attrs.spacingOpen?
+						spacingOpen = parseInt(attrs.spacingOpen, 10)
+					else
+						spacingOpen = 24
+
+					if attrs.spacingClosed?
+						spacingClosed = parseInt(attrs.spacingClosed, 10)
+					else
+						spacingClosed = 24
+
+					options =
+						spacing_open: spacingOpen
+						spacing_closed: spacingClosed
+						slidable: false
+						onresize: () =>
+							onInternalResize()
+						maskIframesOnResize: scope.$eval(
+							attrs.maskIframesOnResize or "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 = $.localStorage("layout.#{name}"))?
+						options.west = state.west
+						options.east = state.east
+
+					# Someone moved the resizer
+					onInternalResize = () ->
+						state = element.layout().readState()
+						scope.$broadcast "layout:#{name}:resize", state
+						repositionControls()
+						resetOpenStates()
+
+					oldWidth = element.width()
+					# Something resized our parent element
+					onExternalResize = () ->
+						console.log "EXTERNAL RESIOZE", name, attrs.resizeProportionally
+						if attrs.resizeProportionally? and scope.$eval(attrs.resizeProportionally)
+							eastState = element.layout().readState().east
+							if eastState?
+								newInternalWidth = eastState.size / oldWidth * element.width()
+								oldWidth = element.width()
+								element.layout().sizePane("east", newInternalWidth)
+								return
+							
+						element.layout().resizeAll()
+
+					element.layout options
+					element.layout().resizeAll()
+
+					if attrs.resizeOn?
+						scope.$on attrs.resizeOn, () -> onExternalResize()
+
+					# Save state when exiting
+					$(window).unload () ->
+						$.localStorage("layout.#{name}", element.layout().readState())
+
+					repositionControls = () ->
+						state = element.layout().readState()
+						if state.east?
+							element.find("> .ui-layout-resizer-controls").css({
+								position: "absolute"
+								right: state.east.size
+								"z-index": 10
+							})
+
+					resetOpenStates = () ->
+						state = element.layout().readState()
+						if attrs.openEast?
+							openEast = $parse(attrs.openEast)
+							openEast.assign(scope, !state.east.initClosed)
+
+					if attrs.openEast?
+						scope.$watch attrs.openEast, (value, oldValue) ->
+							console.log "Open East", value, oldValue
+							if value? and value != oldValue
+								if value
+									element.layout().open("east")
+								else
+									element.layout().close("east")
+							setTimeout () ->
+								scope.$digest()
+							, 0
+
+					resetOpenStates()
+		}
+	]

+ 253 - 0
services/web/public/coffee/ide/editor/Document.coffee

@@ -0,0 +1,253 @@
+define [
+	"utils/EventEmitter"
+	"ide/editor/ShareJsDoc"
+], (EventEmitter, ShareJsDoc) ->
+	class Document extends EventEmitter
+		@getDocument: (ide, doc_id) ->
+			@openDocs ||= {}
+			if !@openDocs[doc_id]?
+				@openDocs[doc_id] = new Document(ide, doc_id)
+			return @openDocs[doc_id]
+
+		@hasUnsavedChanges: () ->
+			for doc_id, doc of (@openDocs or {})
+				return true if doc.hasBufferedOps()
+			return false
+
+		constructor: (@ide, @doc_id) ->
+			@connected = @ide.socket.socket.connected
+			@joined = false
+			@wantToBeJoined = false
+			@_checkConsistency = _.bind(@_checkConsistency, @)
+			@inconsistentCount = 0
+			@_bindToEditorEvents()
+			@_bindToSocketEvents()
+
+		attachToAce: (@ace) ->
+			@doc?.attachToAce(@ace)
+			editorDoc = @ace.getSession().getDocument()
+			editorDoc.on "change", @_checkConsistency
+
+		detachFromAce: () ->
+			@doc?.detachFromAce()
+			editorDoc = @ace?.getSession().getDocument()
+			editorDoc?.off "change", @_checkConsistency
+
+		_checkConsistency: () ->
+			# 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.
+			setTimeout () =>
+				editorValue = @ace?.getValue()
+				sharejsValue = @doc?.getSnapshot()
+				if editorValue != sharejsValue
+					@inconsistentCount++
+				else
+					@inconsistentCount = 0
+
+				if @inconsistentCount >= 3
+					@_onError new Error("Editor text does not match server text")
+			, 0
+
+		getSnapshot: () ->
+			@doc?.getSnapshot()
+
+		getType: () ->
+			@doc?.getType()
+
+		getInflightOp: () ->
+			@doc?.getInflightOp()
+
+		getPendingOp: () ->
+			@doc?.getPendingOp()
+
+		hasBufferedOps: () ->
+			@doc?.hasBufferedOps()
+
+		_bindToSocketEvents: () ->
+			@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
+			@ide.socket.on "otUpdateApplied", @_onUpdateAppliedHandler
+			@_onErrorHandler = (error, update) => @_onError(error, update)
+			@ide.socket.on "otUpdateError", @_onErrorHandler
+			@_onDisconnectHandler = (error) => @_onDisconnect(error)
+			@ide.socket.on "disconnect", @_onDisconnectHandler
+
+		_bindToEditorEvents: () ->
+			onReconnectHandler = (update) =>
+				@_onReconnect(update)
+			@_unsubscribeReconnectHandler = @ide.$scope.$on "project:joined", onReconnectHandler
+
+		_unBindFromEditorEvents: () ->
+			@_unsubscribeReconnectHandler()
+
+		_unBindFromSocketEvents: () ->
+			@ide.socket.removeListener "otUpdateApplied", @_onUpdateAppliedHandler
+			@ide.socket.removeListener "otUpdateError", @_onUpdateErrorHandler
+			@ide.socket.removeListener "disconnect", @_onDisconnectHandler
+
+		leaveAndCleanUp: () ->
+			@leave (error) =>
+				@_cleanUp()
+
+		join: (callback = (error) ->) ->
+			@wantToBeJoined = true
+			@_cancelLeave()
+			if @connected
+				return @_joinDoc callback
+			else
+				@_joinCallbacks ||= []
+				@_joinCallbacks.push callback
+
+		leave: (callback = (error) ->) ->
+			@wantToBeJoined = false
+			@_cancelJoin()
+			if (@doc? and @doc.hasBufferedOps())
+				@_leaveCallbacks ||= []
+				@_leaveCallbacks.push callback
+			else if !@connected
+				callback()
+			else
+				@_leaveDoc(callback)
+
+		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.
+			inflightOp = @getInflightOp()
+			pendingOp = @getPendingOp()
+			if !inflightOp? and !pendingOp?
+				# there's nothing going on
+				saved = true
+			else if inflightOp == @oldInflightOp
+				saved = false
+			else if pendingOp?
+				saved = false
+			else
+				saved = true
+
+			@oldInflightOp = inflightOp
+			return saved
+
+		_cancelLeave: () ->
+			if @_leaveCallbacks?
+				delete @_leaveCallbacks
+
+		_cancelJoin: () ->
+			if @_joinCallbacks?
+				delete @_joinCallbacks
+
+		_onUpdateApplied: (update) ->
+			@ide.pushEvent "received-update",
+				doc_id: @doc_id
+				remote_doc_id: update?.doc
+				wantToBeJoined: @wantToBeJoined
+				update: update
+
+			if Math.random() < (@ide.disconnectRate or 0)
+				console.log "Simulating disconnect"
+				@ide.connectionManager.disconnect()
+				return
+
+			if Math.random() < (@ide.ignoreRate or 0)
+				console.log "Simulating lost update"
+				return
+
+			if update?.doc == @doc_id and @doc?
+				@doc.processUpdateFromServer update
+
+				if !@wantToBeJoined
+					@leave()
+
+		_onDisconnect: () ->
+			@connected = false
+			@joined = false
+			@doc?.updateConnectionState "disconnected"
+
+		_onReconnect: () ->
+			@ide.pushEvent "reconnected:afterJoinProject"
+
+			@connected = true
+			if @wantToBeJoined or @doc?.hasBufferedOps()
+				@_joinDoc (error) =>
+					return @_onError(error) if error?
+					@doc.updateConnectionState "ok"
+					@doc.flushPendingOps()
+					@_callJoinCallbacks()
+
+		_callJoinCallbacks: () ->
+			for callback in @_joinCallbacks or []
+				callback()
+			delete @_joinCallbacks
+
+		_joinDoc: (callback = (error) ->) ->
+			if @doc?
+				@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) =>
+					return callback(error) if error?
+					@joined = true
+					@doc.catchUp( updates )
+					callback()
+			else
+				@ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) =>
+					return callback(error) if error?
+					@joined = true
+					@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
+					@_bindToShareJsDocEvents()
+					callback()
+
+		_leaveDoc: (callback = (error) ->) ->
+			@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
+				return callback(error) if error?
+				@joined = false
+				for callback in @_leaveCallbacks or []
+					callback(error)
+				delete @_leaveCallbacks
+				callback(error)
+
+		_cleanUp: () ->
+			delete Document.openDocs[@doc_id]
+			@_unBindFromEditorEvents()
+			@_unBindFromSocketEvents()
+
+		_bindToShareJsDocEvents: () ->
+			@doc.on "error", (error, meta) => @_onError error, meta
+			@doc.on "externalUpdate", () => 
+				@ide.pushEvent "externalUpdate",
+					doc_id: @doc_id
+				@trigger "externalUpdate"
+			@doc.on "remoteop", () => 
+				@ide.pushEvent "remoteop",
+					doc_id: @doc_id
+				@trigger "remoteop"
+			@doc.on "op:sent", (op) =>
+				@ide.pushEvent "op:sent",
+					doc_id: @doc_id
+					op: op
+				@trigger "op:sent"
+			@doc.on "op:acknowledged", (op) =>
+				@ide.pushEvent "op:acknowledged",
+					doc_id: @doc_id
+					op: op
+				@trigger "op:acknowledged"
+			@doc.on "op:timeout", (op) =>
+				@ide.pushEvent "op:timeout",
+					doc_id: @doc_id
+					op: op
+				@trigger "op:timeout"
+				ga?('send', 'event', 'error', "op timeout", "Op was now acknowledged - #{@ide.socket.socket.transport.name}" )
+				@ide.connectionManager.reconnectImmediately()
+			@doc.on "flush", (inflightOp, pendingOp, version) =>
+				@ide.pushEvent "flush",
+					doc_id: @doc_id,
+					inflightOp: inflightOp,
+					pendingOp: pendingOp
+					v: version
+
+		_onError: (error, meta = {}) ->
+			console.error "ShareJS error", error, meta
+			ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
+			@ide.socket.disconnect()
+			meta.doc_id = @doc_id
+			@ide.reportError(error, meta)
+			@doc?.clearInflightAndPendingOps()
+			@_cleanUp()
+			@trigger "error", error

+ 103 - 0
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -0,0 +1,103 @@
+define [
+	"ide/editor/Document"
+	"ide/editor/directives/aceEditor"
+	"ide/editor/controllers/SavingNotificationController"
+], (Document) ->
+	class EditorManager
+		constructor: (@ide, @$scope) ->
+			@$scope.editor = {
+				sharejs_doc: null
+				last_updated: null
+				open_doc_id: null
+				opening: true
+				cursorPosition: {}
+				gotoLine: null
+			}
+
+			@$scope.$on "entity:selected", (event, entity) =>
+				if (@$scope.ui.view != "track-changes" and entity.type == "doc")
+					@openDoc(entity)
+
+			initialized = false
+			@$scope.$on "file-tree:initialized", () =>
+				if !initialized
+					initialized = true
+					@autoOpenDoc()
+
+		autoOpenDoc: () ->
+			open_doc_id = 
+				$.localStorage("doc.open_id.#{@$scope.project_id}") or
+				@$scope.project.rootDoc_id
+			return if !open_doc_id?
+			doc = @ide.fileTreeManager.findEntityById(open_doc_id)
+			return if !doc?
+			@openDoc(doc)
+
+		openDoc: (doc, options = {}) ->
+			@$scope.ui.view = "editor"
+
+			done = () =>
+				if options.gotoLine?
+					@$scope.editor.gotoLine = options.gotoLine
+			
+			if doc.id == @$scope.editor.open_doc_id and !options.forceReopen
+				@$scope.$apply () =>
+					done()
+				return
+
+			@$scope.editor.open_doc_id = doc.id
+
+			$.localStorage "doc.open_id.#{@$scope.project_id}", doc.id
+			@ide.fileTreeManager.selectEntity(doc)
+
+			@$scope.editor.opening = true
+			@_openNewDocument doc, (error, sharejs_doc) =>
+				if error?
+					@ide.showGenericServerErrorMessage()
+					return
+
+				@$scope.$broadcast "doc:opened"
+
+				@$scope.$apply () =>
+					@$scope.editor.opening = false
+					@$scope.editor.sharejs_doc = sharejs_doc
+					done()
+
+		_openNewDocument: (doc, callback = (error, sharejs_doc) ->) ->
+			current_sharejs_doc = @$scope.editor.sharejs_doc
+			if current_sharejs_doc?
+				current_sharejs_doc.leaveAndCleanUp()
+				@_unbindFromDocumentEvents(current_sharejs_doc)
+
+			new_sharejs_doc = Document.getDocument @ide, doc.id
+
+			new_sharejs_doc.join (error) =>
+				return callback(error) if error?
+				@_bindToDocumentEvents(doc, new_sharejs_doc)
+				callback null, new_sharejs_doc
+
+		_bindToDocumentEvents: (doc, sharejs_doc) ->
+			sharejs_doc.on "error", (error) =>
+				@openDoc(doc, forceReopen: true)
+				@ide.showGenericMessageModal(
+					"Out of sync"
+					"Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently."
+				)
+
+			sharejs_doc.on "externalUpdate", () =>
+				@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."
+				)
+
+		_unbindFromDocumentEvents: (document) ->
+			document.off()
+
+		lastUpdated: () ->
+			@$scope.editor.last_updated
+
+		getCurrentDocValue: () ->
+			@$scope.editor.sharejs_doc?.getSnapshot()
+
+		getCurrentDocId: () ->
+			@$scope.editor.open_doc_id

+ 123 - 0
services/web/public/coffee/ide/editor/ShareJsDoc.coffee

@@ -0,0 +1,123 @@
+define [
+	"utils/EventEmitter"
+	"libs/sharejs"
+], (EventEmitter, ShareJs) ->
+	class ShareJsDoc extends EventEmitter
+		constructor: (@doc_id, docLines, version, @socket) ->
+			# Dencode any binary bits of data
+			# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
+			@type = "text"
+			docLines = for line in docLines
+				if line.text?
+					@type = "json"
+					line.text = decodeURIComponent(escape(line.text))
+				else
+					@type = "text"
+					line = decodeURIComponent(escape(line))
+				line
+
+			if @type == "text"
+				snapshot = docLines.join("\n")
+			else if @type == "json"
+				snapshot = { lines: docLines }
+			else
+				throw new Error("Unknown type: #{@type}")
+
+			@connection = {
+				send: (update) =>
+					@_startInflightOpTimeout(update)
+					@socket.emit "applyOtUpdate", @doc_id, update
+				state: "ok"
+				id:    @socket.socket.sessionid
+			}
+
+			@_doc = new ShareJs.Doc @connection, @doc_id,
+				type: @type
+			@_doc.on "change", () =>
+				@trigger "change"
+			@_doc.on "acknowledge", () =>
+				@trigger "acknowledge"
+			@_doc.on "remoteop", () =>
+				@trigger "remoteop"
+
+			@_bindToDocChanges(@_doc)
+
+			@processUpdateFromServer
+				open: true
+				v: version
+				snapshot: snapshot
+
+		submitOp: (args...) -> @_doc.submitOp(args...)
+
+		processUpdateFromServer: (message) ->
+			try
+				@_doc._onMessage message
+			catch error
+				# Version mismatches are thrown as errors
+				@_handleError(error)
+
+			if message?.meta?.type == "external"
+				@trigger "externalUpdate"
+
+		catchUp: (updates) ->
+			for update, i in updates
+				update.v   = @_doc.version
+				update.doc = @doc_id
+				@processUpdateFromServer(update)
+
+		getSnapshot: () -> @_doc.snapshot
+		getVersion: () -> @_doc.version
+		getType: () -> @type
+
+		clearInflightAndPendingOps: () ->
+			@_doc.inflightOp = null
+			@_doc.inflightCallbacks = []
+			@_doc.pendingOp = null
+			@_doc.pendingCallbacks = []
+
+		flushPendingOps: () ->
+			# This will flush any ops that are pending.
+			# If there is an inflight op it will do nothing.
+			@_doc.flush()
+
+		updateConnectionState: (state) ->
+			@connection.state = state
+			@connection.id = @socket.socket.sessionid
+			@_doc.autoOpen = false
+			@_doc._connectionStateChanged(state)
+
+		hasBufferedOps: () ->
+			@_doc.inflightOp? or @_doc.pendingOp?
+
+		getInflightOp: () -> @_doc.inflightOp
+		getPendingOp: () -> @_doc.pendingOp
+
+		attachToAce: (ace) -> @_doc.attach_ace(ace)
+		detachFromAce: () -> @_doc.detach_ace?()
+	
+		INFLIGHT_OP_TIMEOUT: 10000
+		_startInflightOpTimeout: (update) ->
+			meta =
+				v: update.v
+				op_sent_at: new Date()
+			timer = setTimeout () =>
+				@trigger "op:timeout", update
+			, @INFLIGHT_OP_TIMEOUT
+			@_doc.inflightCallbacks.push () =>
+				clearTimeout timer
+
+		_handleError: (error, meta = {}) ->
+			@trigger "error", error, meta
+
+		_bindToDocChanges: (doc) ->
+			submitOp = doc.submitOp
+			doc.submitOp = (args...) =>
+				@trigger "op:sent", args...
+				doc.pendingCallbacks.push () =>
+					@trigger "op:acknowledged", args...
+				submitOp.apply(doc, args)
+
+			flush = doc.flush
+			doc.flush = (args...) =>
+				@trigger "flush", doc.inflightOp, doc.pendingOp, doc.version
+				flush.apply(doc, args)

+ 33 - 0
services/web/public/coffee/ide/editor/controllers/SavingNotificationController.coffee

@@ -0,0 +1,33 @@
+define [
+	"base"
+	"ide/editor/Document"
+], (App, Document) ->
+	App.controller "SavingNotificationController", ["$scope", "$interval", "ide", ($scope, $interval, ide) ->
+		$interval () ->
+			pollSavedStatus()
+		, 1000
+
+		$(window).bind 'beforeunload', () =>
+			warnAboutUnsavedChanges()
+
+		$scope.docSavingStatus = {}
+		pollSavedStatus = () ->
+			oldStatus = $scope.docSavingStatus
+			$scope.docSavingStatus = {}
+
+			for doc_id, doc of Document.openDocs
+				saving = doc.pollSavedStatus()
+				if !saving
+					if oldStatus[doc_id]?
+						$scope.docSavingStatus[doc_id] = oldStatus[doc_id]
+						$scope.docSavingStatus[doc_id].unsavedSeconds += 1
+					else
+						$scope.docSavingStatus[doc_id] = {
+							unsavedSeconds: 0
+							doc: ide.fileTreeManager.findEntityById(doc_id)
+						}
+
+		warnAboutUnsavedChanges = () ->
+			if Document.hasUnsavedChanges()
+				return "You have unsaved changes. If you leave now they will not be saved."
+	]

+ 187 - 0
services/web/public/coffee/ide/editor/directives/aceEditor.coffee

@@ -0,0 +1,187 @@
+define [
+	"base"
+	"ace/ace"
+	"ide/editor/directives/aceEditor/undo/UndoManager"
+	"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
+	"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
+	"ide/editor/directives/aceEditor/highlights/HighlightsManager"
+	"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
+	"ace/keyboard/vim"
+	"ace/keyboard/emacs"
+	"ace/mode/latex"
+	"ace/edit_session"
+], (App, Ace, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager) ->
+	LatexMode = require("ace/mode/latex").Mode
+	EditSession = require('ace/edit_session').EditSession
+
+	App.directive "aceEditor", ["$timeout", ($timeout) ->
+		return  {
+			scope: {
+				theme: "="
+				showPrintMargin: "="
+				keybindings: "="
+				fontSize: "="
+				autoComplete: "="
+				sharejsDoc: "="
+				lastUpdated: "="
+				spellCheckLanguage: "="
+				cursorPosition: "="
+				highlights: "="
+				text: "="
+				readOnly: "="
+				gotoLine: "="
+				annotations: "="
+			}
+			link: (scope, element, attrs) ->
+				# Don't freak out if we're already in an apply callback
+				scope.$originalApply = scope.$apply
+				scope.$apply = (fn = () ->) ->
+					phase = @$root.$$phase
+					if (phase == '$apply' || phase == '$digest')
+						fn()
+					else
+						@$originalApply(fn);
+
+				editor = Ace.edit(element.find(".ace-editor-body")[0])
+				window.editors ||= []
+				window.editors.push editor
+
+				autoCompleteManager   = new AutoCompleteManager(scope, editor, element)
+				spellCheckManager     = new SpellCheckManager(scope, editor, element)
+				undoManager           = new UndoManager(scope, editor, element)
+				highlightsManager     = new HighlightsManager(scope, editor, element)
+				cursorPositionManager = new CursorPositionManager(scope, editor, element)
+
+				# Prevert Ctrl|Cmd-S from triggering save dialog
+				editor.commands.addCommand
+					name: "save",
+					bindKey: win: "Ctrl-S", mac: "Command-S"
+					exec: () ->
+					readOnly: true
+				editor.commands.removeCommand "transposeletters"
+				editor.commands.removeCommand "showSettingsMenu"
+				editor.commands.removeCommand "foldall"
+
+				if attrs.resizeOn?
+					for event in attrs.resizeOn.split(",")
+						scope.$on event, () ->
+							editor.resize()
+
+				scope.$watch "theme", (value) ->
+					editor.setTheme("ace/theme/#{value}")
+
+				scope.$watch "showPrintMargin", (value) ->
+					editor.setShowPrintMargin(value)
+
+				scope.$watch "keybindings", (value) ->
+					Vim = require("ace/keyboard/vim").handler
+					Emacs = require("ace/keyboard/emacs").handler
+					keybindings = vim: Vim, emacs: Emacs
+					editor.setKeyboardHandler(keybindings[value])
+
+				scope.$watch "fontSize", (value) ->
+					element.find(".ace_editor, .ace_content").css({
+						"font-size": value + "px"
+					})
+
+				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
+					if old_sharejs_doc?
+						detachFromAce(old_sharejs_doc)
+
+					if sharejs_doc?
+						attachToAce(sharejs_doc)
+
+				scope.$watch "text", (text) ->
+					if text?
+						editor.setValue(text, -1)
+						session = editor.getSession()
+						session.setUseWrapMode(true)
+						session.setMode(new LatexMode())
+
+				scope.$watch "annotations", (annotations) ->
+					console.log "SETTING ANNOTATIONS", annotations
+					if annotations?
+						session = editor.getSession() 
+						session.setAnnotations annotations
+
+				scope.$watch "readOnly", (value) ->
+					editor.setReadOnly !!value
+
+				resetSession = () ->
+					session = editor.getSession()
+					session.setUseWrapMode(true)
+					session.setMode(new LatexMode())
+					session.setAnnotations scope.annotations
+
+				attachToAce = (sharejs_doc) ->
+					lines = sharejs_doc.getSnapshot().split("\n") 
+					editor.setSession(new EditSession(lines))
+					resetSession()
+					session = editor.getSession()
+
+					doc = session.getDocument()
+					doc.on "change", () ->
+						scope.$apply () ->
+							scope.lastUpdated = new Date()
+
+					sharejs_doc.on "remoteop.recordForUndo", () =>
+						undoManager.nextUpdateIsRemote = true
+
+					sharejs_doc.attachToAce(editor)
+
+					editor.focus()
+
+				detachFromAce = (sharejs_doc) ->
+					sharejs_doc.detachFromAce()
+					sharejs_doc.off "remoteop.recordForUndo"
+
+			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>
+					<div 
+						id="spellCheckMenu"
+						class="dropdown context-menu"
+						ng-show="spellingMenu.open"
+						ng-style="{top: spellingMenu.top, left: spellingMenu.left}"
+						ng-class="{open: spellingMenu.open}"
+					>
+						<ul class="dropdown-menu">
+							<li ng-repeat="suggestion in spellingMenu.highlight.suggestions | limitTo:8">
+								<a href ng-click="replaceWord(spellingMenu.highlight, suggestion)">{{ suggestion }}</a>
+							</li>
+							<li class="divider"></li>
+							<li>
+								<a href ng-click="learnWord(spellingMenu.highlight)">Add to Dictionary</a>
+							</li>
+						</ul>
+					</div>
+					<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>
+				</div>
+			"""
+		}
+	]

+ 95 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager.coffee

@@ -0,0 +1,95 @@
+define [
+	"ide/editor/auto-complete/SuggestionManager"
+	"ide/editor/auto-complete/Snippets"
+	"ace/autocomplete/util"
+	"ace/autocomplete"
+	"ace/range"
+	"ace/ext/language_tools"
+], (SuggestionManager, Snippets, Util, AutoComplete) ->
+	Range = require("ace/range").Range
+	Autocomplete = AutoComplete.Autocomplete
+
+	Util.retrievePrecedingIdentifier = (text, pos, regex) ->
+		currentLineOffset = 0
+		for i in [(pos-1)..0]
+			if text[i] == "\n"
+				currentLineOffset = i + 1
+				break
+		currentLine = text.slice(currentLineOffset, pos)
+		fragment = getLastCommandFragment(currentLine) or ""
+		return fragment
+
+	getLastCommandFragment = (lineUpToCursor) ->
+		if m = lineUpToCursor.match(/(\\[^\\ ]+)$/)
+			return m[1]
+		else
+			return null
+
+	class AutoCompleteManager
+		constructor: (@$scope, @editor) ->
+			@suggestionManager = new SuggestionManager()
+
+			if !Autocomplete::_insertMatch?
+				# Only override this once since it's global but we may create multiple
+				# autocomplete handlers
+				Autocomplete::_insertMatch = Autocomplete::insertMatch
+				Autocomplete::insertMatch = (data) ->
+					pos = editor.getCursorPosition()
+					range = new Range(pos.row, pos.column, pos.row, pos.column + 1)
+					nextChar = editor.session.getTextRange(range)
+
+					console.log "INSERT MATCH", this
+
+					# 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 this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
+						editor.session.remove(range)
+					
+					Autocomplete::_insertMatch.call this, data
+
+			@$scope.$watch "autoComplete", (autocomplete) =>
+				console.log "autocomplete change", autocomplete
+				if autocomplete
+					@enable()
+				else
+					@disable()
+
+
+			@editor.on "changeSession", (e) =>
+				@bindToSession(e.session)
+
+		enable: () ->
+			@editor.setOptions({
+				enableBasicAutocompletion: true,
+				enableSnippets: true
+			})
+			
+			SnippetCompleter =
+				getCompletions: (editor, session, pos, prefix, callback) ->
+					callback null, Snippets
+			@editor.completers = [@suggestionManager, SnippetCompleter]
+
+		disable: () ->
+			@editor.setOptions({
+				enableBasicAutocompletion: false,
+				enableSnippets: false
+			})
+
+		bindToSession: (@aceSession) ->
+			@aceSession.on "change", (change) => @onChange(change)
+
+		onChange: (change) ->
+			cursorPosition = @editor.getCursorPosition()
+			end = change.data.range.end
+			# Check that this change was made by us, not a collaborator
+			# (Cursor is still one place behind)
+			if end.row == cursorPosition.row and end.column == cursorPosition.column + 1
+				if change.data.action == "insertText"
+					range = new Range(end.row, 0, end.row, end.column)
+					lineUpToCursor = @aceSession.getTextRange(range)
+					commandFragment = getLastCommandFragment(lineUpToCursor)
+
+					if commandFragment? and commandFragment.length > 2
+					 	setTimeout () =>
+					 		@editor.execCommand("startAutocomplete")
+					 	, 0

+ 100 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/Snippets.coffee

@@ -0,0 +1,100 @@
+define () ->
+	environments = [
+		"abstract", 
+		"align", "align*",
+		"equation", "equation*",
+		"gather", "gather*",
+		"multline", "multline*",
+		"split",
+		"verbatim"
+	]
+
+	snippets = for env in environments
+		{
+			caption: "\\begin{#{env}}..."
+			snippet: """
+				\\begin{#{env}}
+				$1
+				\\end{#{env}}
+			"""
+			meta: "env"
+		}
+
+	snippets = snippets.concat [{
+		caption: "\\begin{array}..."
+		snippet: """
+			\\begin{array}{${1:cc}}
+			$2 & $3 \\\\\\\\
+			$4 & $5
+			\\end{array}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{figure}..."
+		snippet: """
+			\\begin{figure}
+			\\centering
+			\\includegraphics{$1}
+			\\caption{${2:Caption}}
+			\\label{${3:fig:my_label}}
+			\\end{figure}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{tabular}..."
+		snippet: """
+			\\begin{tabular}{${1:c|c}}
+			$2 & $3 \\\\\\\\
+			$4 & $5
+			\\end{tabular}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{table}..."
+		snippet: """
+			\\begin{table}[$1]
+			\\centering
+			\\begin{tabular}{${2:c|c}}
+			$3 & $4 \\\\\\\\
+			$5 & $6
+			\\end{tabular}
+			\\caption{${7:Caption}}
+			\\label{${8:tab:my_label}}
+			\\end{table}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{list}..."
+		snippet: """
+			\\begin{list}
+			\\item $1
+			\\end{list}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{enumerate}..."
+		snippet: """
+			\\begin{enumerate}
+			\\item $1
+			\\end{enumerate}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{itemize}..."
+		snippet: """
+			\\begin{itemize}
+			\\item $1
+			\\end{itemize}
+		"""
+		meta: "env"
+	}, {
+		caption: "\\begin{frame}..."
+		snippet: """
+			\\begin{frame}{${1:Frame Title}}
+			$2
+			\\end{frame}
+		"""
+		meta: "env"
+	}]
+
+	return snippets

+ 126 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/auto-complete/SuggestionManager.coffee

@@ -0,0 +1,126 @@
+define [], () ->
+	class Parser
+		constructor: (@doc) ->
+
+		parse: () ->
+			commands = []
+			seen = {}
+			while command = @nextCommand()
+				docState = @doc
+
+				optionalArgs = 0
+				while @consumeArgument("[", "]")
+					optionalArgs++
+
+				args = 0
+				while @consumeArgument("{", "}")
+					args++
+
+				commandHash = "#{command}\\#{optionalArgs}\\#{args}"
+				if !seen[commandHash]?
+					seen[commandHash] = true
+					commands.push [command, optionalArgs, args]
+
+				# Reset to before argument to handle nested commands
+				@doc = docState
+
+			return commands
+
+		# Ignore single letter commands since auto complete is moot then.
+		commandRegex: /\\([a-zA-Z][a-zA-Z]+)/
+		
+		nextCommand: () ->
+			i = @doc.search(@commandRegex)
+			if i == -1
+				return false
+			else
+				match = @doc.match(@commandRegex)[1]
+				@doc = @doc.substr(i + match.length + 1)
+				return match
+
+		consumeWhitespace: () ->
+			match = @doc.match(/^[ \t\n]*/m)[0]
+			@doc = @doc.substr(match.length)
+
+		consumeArgument: (openingBracket, closingBracket) ->
+			@consumeWhitespace()
+
+			if @doc[0] == openingBracket
+				i = 1
+				bracketParity = 1
+				while bracketParity > 0 and i < @doc.length
+					if @doc[i] == openingBracket
+						bracketParity++
+					else if @doc[i] == closingBracket
+						bracketParity--
+					i++
+
+				if bracketParity == 0
+					@doc = @doc.substr(i)
+					return true
+				else
+					return false
+			else
+				return false
+
+	class SuggestionManager
+		getCompletions: (editor, session, pos, prefix, callback) ->
+			doc = session.getValue()
+			parser = new Parser(doc)
+			commands = parser.parse()
+
+			completions = []
+			for command in commands
+				caption = "\\#{command[0]}"
+				snippet = caption
+				i = 1
+				_.times command[1], () ->
+					snippet += "[${#{i}}]"
+					caption += "[]"
+					i++
+				_.times command[2], () ->
+					snippet += "{${#{i}}}"
+					caption += "{}"
+					i++
+				unless caption == prefix
+					completions.push {
+						caption: caption
+						snippet: snippet
+						meta: "cmd"
+					}
+
+			callback null, completions
+
+		loadCommandsFromDoc: (doc) ->
+			parser = new Parser(doc)
+			@commands = parser.parse()
+
+		getSuggestions: (commandFragment) ->
+			matchingCommands = _.filter @commands, (command) ->
+				command[0].slice(0, commandFragment.length) == commandFragment
+
+			return _.map matchingCommands, (command) ->
+				base = "\\" + commandFragment
+
+				args = ""
+				_.times command[1], () -> args = args + "[]"
+				_.times command[2], () -> args = args + "{}"
+				completionBase = command[0].slice(commandFragment.length)
+
+				squareArgsNo = command[1]
+				curlyArgsNo = command[2]
+				totalArgs = squareArgsNo + curlyArgsNo
+				if totalArgs == 0
+					completionBeforeCursor = completionBase
+					completionAfterCurspr = ""
+				else
+					completionBeforeCursor = completionBase + args[0]
+					completionAfterCursor = args.slice(1)
+
+				return {
+					base: base,
+					completion: completionBase + args,
+					completionBeforeCursor: completionBeforeCursor
+					completionAfterCursor: completionAfterCursor
+				}
+

+ 52 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.coffee

@@ -0,0 +1,52 @@
+define [], () ->
+	class CursorPositionManager
+		constructor: (@$scope, @editor, @element) ->
+
+			@editor.on "changeSession", (e) =>
+				e.session.on "changeScrollTop", (e) =>
+					@onScrollTopChange(e)
+
+				e.session.selection.on 'changeCursor', (e) =>
+					@onCursorChange(e)
+
+				@gotoStoredPosition()
+
+			@editor.on "changeSelection", () =>
+				cursor = @editor.getCursorPosition()
+				@$scope.$apply () =>
+					if @$scope.cursorPosition?
+						@$scope.cursorPosition = cursor
+
+			@$scope.$watch "gotoLine", (value) =>
+				console.log "Going to line", value
+				if value?
+					setTimeout () =>
+						@gotoLine(value)
+						@$scope.$apply () =>
+							@$scope.gotoLine = null
+					, 0
+
+		onScrollTopChange: (event) ->
+			if !@ignoreCursorPositionChanges and doc_id = @$scope.sharejsDoc?.doc_id
+				docPosition = $.localStorage("doc.position.#{doc_id}") || {}
+				docPosition.scrollTop = @editor.getSession().getScrollTop()
+				$.localStorage("doc.position.#{doc_id}", docPosition)
+			
+		onCursorChange: (event) ->
+			if !@ignoreCursorPositionChanges and doc_id = @$scope.sharejsDoc?.doc_id
+				docPosition = $.localStorage("doc.position.#{doc_id}") || {}
+				docPosition.cursorPosition = @editor.getCursorPosition()
+				$.localStorage("doc.position.#{doc_id}", docPosition)
+
+		gotoStoredPosition: () ->
+			doc_id = @$scope.sharejsDoc?.doc_id
+			return if !doc_id?
+			pos = $.localStorage("doc.position.#{doc_id}") || {}
+			@ignoreCursorPositionChanges = true
+			@editor.moveCursorToPosition(pos.cursorPosition or {row: 0, column: 0})
+			@editor.getSession().setScrollTop(pos.scrollTop or 0)
+			delete @ignoreCursorPositionChanges
+
+		gotoLine: (line) ->
+			@editor.gotoLine(line)
+			@editor.focus()

+ 228 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee

@@ -0,0 +1,228 @@
+define [
+	"ace/range"
+], () ->
+	Range = require("ace/range").Range
+
+	class HighlightsManager
+		constructor: (@$scope, @editor, @element) ->
+			@markerIds = []
+			@labels = []
+
+			@$scope.annotationLabel = {
+				show: false
+				right:  "auto"
+				left:   "auto"
+				top:    "auto"
+				bottom: "auto"
+				backgroundColor: "black"
+				text: ""
+			}
+
+			@$scope.$watch "highlights", (value) =>
+				@redrawAnnotations()
+
+			@$scope.$watch "theme", (value) =>
+				@redrawAnnotations()
+
+			@editor.on "mousemove", (e) =>
+				position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
+				e.position = position
+				@showAnnotationLabels(position)
+
+			@editor.on "changeSession", () =>
+				@redrawAnnotations()
+
+		redrawAnnotations: () ->
+			@_clearMarkers()
+			@_clearLabels()
+
+			for annotation in @$scope.highlights or []
+				do (annotation) =>
+					colorScheme = @_getColorScheme(annotation.hue)
+					if annotation.cursor?
+						console.log "DRAWING CURSOR", annotation
+						@labels.push {
+							text: annotation.label
+							range: new Range(
+								annotation.cursor.row, annotation.cursor.column,
+								annotation.cursor.row, annotation.cursor.column + 1
+							)
+							colorScheme: colorScheme
+							snapToStartOfRange: true
+						}
+						@_drawCursor(annotation, colorScheme)
+					else if annotation.highlight?
+						@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: colorScheme
+						}
+						@_drawHighlight(annotation, colorScheme)
+					else if annotation.strikeThrough?
+						@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: colorScheme
+						}
+						@_drawStrikeThrough(annotation, colorScheme)
+
+		showAnnotationLabels: (position) ->
+			labelToShow = null
+			for label in @labels or []
+				if label.range.contains(position.row, position.column)
+					labelToShow = label
+
+			if !labelToShow?
+				@$scope.$apply () =>
+					@$scope.annotationLabel.show = false
+			else
+				$ace = $(@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(@element.find(".annotation-label"))
+
+				if labelToShow.snapToStartOfRange
+					coords = @editor.renderer.textToScreenCoordinates(labelToShow.range.start.row, labelToShow.range.start.column)
+				else
+					coords = @editor.renderer.textToScreenCoordinates(position.row, position.column)
+
+				offset = $ace.offset()
+				height = $ace.height()
+				coords.pageX = coords.pageX - offset.left
+				coords.pageY = coords.pageY - offset.top
+
+				if coords.pageY > @editor.renderer.lineHeight * 2
+					top    = "auto"
+					bottom = height - coords.pageY
+				else
+					top    = coords.pageY + @editor.renderer.lineHeight
+					bottom = "auto"
+
+				# Apply this first that the label has the correct width when calculating below
+				@$scope.$apply () =>
+					@$scope.annotationLabel.text = labelToShow.text
+					@$scope.annotationLabel.show = true
+
+				$label = @element.find(".annotation-label")
+				console.log "pageX", coords.pageX, "label", $label.outerWidth(), "ace", $ace.width()
+
+				if coords.pageX + $label.outerWidth() < $ace.width()
+					left  = coords.pageX
+					right = "auto"
+				else
+					right = 0
+					left = "auto"
+
+				@$scope.$apply () =>
+					@$scope.annotationLabel = {
+						show:   true
+						left:   left
+						right:  right
+						bottom: bottom
+						top:    top
+						backgroundColor: labelToShow.colorScheme.labelBackgroundColor
+						text:   labelToShow.text
+					}
+
+		_clearMarkers: () ->
+			for marker_id in @markerIds
+				@editor.getSession().removeMarker(marker_id)
+			@markerIds = []
+
+		_clearLabels: () ->
+			@labels = []
+
+		_drawCursor: (annotation, colorScheme) ->
+			@markerIds.push @editor.getSession().addMarker new Range(
+				annotation.cursor.row, annotation.cursor.column,
+				annotation.cursor.row, annotation.cursor.column + 1
+			), "annotation remote-cursor", (html, range, left, top, config) ->
+				div = """
+					<div
+						class='remote-cursor custom ace_start'
+						style='height: #{config.lineHeight}px; top:#{top}px; left:#{left}px; border-color: #{colorScheme.cursor};'
+					>
+						<div class="nubbin" style="bottom: #{config.lineHeight}px; background-color: #{colorScheme.cursor};"></div>
+					</div>
+				"""
+				html.push div
+			, true
+
+		_drawHighlight: (annotation, colorScheme) ->
+			@_addMarkerWithCustomStyle(
+				new Range(
+					annotation.highlight.start.row, annotation.highlight.start.column,
+					annotation.highlight.end.row,   annotation.highlight.end.column + 1
+				),
+				"annotation highlight",
+				false,
+				"background-color: #{colorScheme.highlightBackgroundColor}"
+			)
+
+		_drawStrikeThrough: (annotation, colorScheme) ->
+			lineHeight = @editor.renderer.lineHeight
+			@_addMarkerWithCustomStyle(
+				new Range(
+					annotation.strikeThrough.start.row, annotation.strikeThrough.start.column,
+					annotation.strikeThrough.end.row,   annotation.strikeThrough.end.column + 1
+				),
+				"annotation strike-through-background",
+				false,
+				"background-color: #{colorScheme.strikeThroughBackgroundColor}"
+			)
+			@_addMarkerWithCustomStyle(
+				new Range(
+					annotation.strikeThrough.start.row, annotation.strikeThrough.start.column,
+					annotation.strikeThrough.end.row,   annotation.strikeThrough.end.column + 1
+				), 
+				"annotation strike-through-foreground",
+				true, 
+				"""
+					height: #{Math.round(lineHeight/2) + 2}px;
+					border-bottom: 2px solid #{colorScheme.strikeThroughForegroundColor};
+				"""
+			)
+
+		_addMarkerWithCustomStyle: (range, klass, foreground, style) ->
+			if foreground?
+				markerLayer = @editor.renderer.$markerBack
+			else
+				markerLayer = @editor.renderer.$markerFront
+
+			@markerIds.push @editor.getSession().addMarker range, klass, (html, range, left, top, config) ->
+				if range.isMultiLine()
+					markerLayer.drawTextMarker(html, range, klass, config, style)
+				else
+					markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
+			, foreground
+
+		_getColorScheme: (hue) ->
+			if @_isDarkTheme()
+				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: () ->
+			rgb = @element.find(".ace_editor").css("background-color");
+			[m, r, g, b] = 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

+ 150 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee

@@ -0,0 +1,150 @@
+define [
+	"ace/range"
+], () ->
+	Range = require("ace/range").Range
+
+	class Highlight
+		constructor: (options) ->
+			@row = options.row
+			@column = options.column
+			@word = options.word
+			@suggestions = options.suggestions
+
+	class HighlightedWordManager
+		constructor: (@editor) ->
+			@highlights = rows: []
+
+		addHighlight: (highlight) ->
+			unless highlight instanceof Highlight
+				highlight = new Highlight(highlight)
+			range = new Range(
+				highlight.row, highlight.column,
+				highlight.row, highlight.column + highlight.word.length
+			)
+			highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight"
+			@highlights.rows[highlight.row] ||= []
+			@highlights.rows[highlight.row].push highlight
+
+		removeHighlight: (highlight) ->
+			@editor.getSession().removeMarker(highlight.markerId)
+			for h, i in @highlights.rows[highlight.row]
+				if h == highlight
+					@highlights.rows[highlight.row].splice(i, 1)
+
+		removeWord: (word) ->
+			toRemove = []
+			for row in @highlights.rows
+				for highlight in (row || [])
+					if highlight.word == word
+						toRemove.push(highlight)
+			for highlight in toRemove
+				@removeHighlight highlight
+
+		moveHighlight: (highlight, position) ->
+			@removeHighlight highlight
+			highlight.row = position.row
+			highlight.column = position.column
+			@addHighlight highlight
+
+		clearRows: (from, to) ->
+			from ||= 0
+			to ||= @highlights.rows.length - 1
+			for row in @highlights.rows.slice(from, to + 1)
+				for highlight in (row || []).slice(0)
+					@removeHighlight highlight
+
+		insertRows: (offset, number) ->
+			# rows are inserted after offset. i.e. offset row is not modified
+			affectedHighlights = []
+			for row in @highlights.rows.slice(offset)
+				affectedHighlights.push(highlight) for highlight in (row || [])
+			for highlight in affectedHighlights
+				@moveHighlight highlight,
+					row: highlight.row + number
+					column: highlight.column
+
+		removeRows: (offset, number) ->
+			# offset is the first row to delete
+			affectedHighlights = []
+			for row in @highlights.rows.slice(offset)
+				affectedHighlights.push(highlight) for highlight in (row || [])
+			for highlight in affectedHighlights
+				if highlight.row >= offset + number
+					@moveHighlight highlight,
+						row: highlight.row - number
+						column: highlight.column
+				else
+					@removeHighlight highlight
+
+		findHighlightWithinRange: (range) ->
+			rows = @highlights.rows.slice(range.start.row, range.end.row + 1)
+			for row in rows
+				for highlight in (row || [])
+					if @_doesHighlightOverlapRange(highlight, range.start, range.end)
+						return highlight
+			return null
+
+		applyChange: (change) ->
+			start = change.range.start
+			end = change.range.end
+			if change.action == "insertText"
+				if start.row != end.row
+					rowsAdded = end.row - start.row
+					@insertRows start.row + 1, rowsAdded
+				# make a copy since we're going to modify in place
+				oldHighlights = (@highlights.rows[start.row] || []).slice(0)
+				for highlight in oldHighlights
+					if highlight.column > start.column
+						# insertion was fully before this highlight
+						@moveHighlight highlight,
+							row: end.row
+							column: highlight.column + (end.column - start.column)
+					else if highlight.column + highlight.word.length >= start.column
+						# insertion was inside this highlight
+						@removeHighlight highlight
+
+			else if change.action == "insertLines"
+				@insertRows start.row, change.lines.length
+
+			else if change.action == "removeText"
+				if start.row == end.row
+					oldHighlights = (@highlights.rows[start.row] || []).slice(0)
+				else
+					rowsRemoved = end.row - start.row
+					oldHighlights =
+						(@highlights.rows[start.row] || []).concat(
+							(@highlights.rows[end.row] || [])
+						)
+					@removeRows start.row + 1, rowsRemoved
+
+				for highlight in oldHighlights
+					if @_doesHighlightOverlapRange highlight, start, end
+						@removeHighlight highlight
+					else if @_isHighlightAfterRange highlight, start, end
+						@moveHighlight highlight,
+							row: start.row
+							column: highlight.column - (end.column - start.column)
+
+			else if change.action == "removeLines"
+				@removeRows start.row, change.lines.length
+
+		_doesHighlightOverlapRange: (highlight, start, end) ->
+			highlightIsAllBeforeRange =
+				highlight.row < start.row or
+				(highlight.row == start.row and highlight.column + highlight.word.length <= start.column)
+			highlightIsAllAfterRange =
+				highlight.row > end.row or
+				(highlight.row == end.row and highlight.column >= end.column)
+			!(highlightIsAllBeforeRange or highlightIsAllAfterRange)
+
+		_isHighlightAfterRange: (highlight, start, end) ->
+			return true if highlight.row > end.row
+			return false if highlight.row < end.row
+			highlight.column >= end.column
+			
+
+
+			
+
+	
+

+ 191 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee

@@ -0,0 +1,191 @@
+define [
+	"ide/editor/spell-check/HighlightedWordManager"
+	"ace/range"
+], (HighlightedWordManager) ->
+	Range = require("ace/range").Range
+
+	class SpellCheckManager
+		constructor: (@$scope, @editor, @element) ->
+			@updatedLines = []
+			@highlightedWordManager = new HighlightedWordManager(@editor)
+
+			@$scope.$watch "spellCheckLanguage", (language, oldLanguage) =>
+				if language != oldLanguage and oldLanguage?
+					@runFullCheck()
+
+			@editor.on "changeSession", (e) =>
+				@runFullCheck()
+
+				doc = e.session.getDocument()
+				doc.on "change", (e) =>
+					@runCheckOnChange(e)
+
+			@$scope.spellingMenu = {left: '0px', top: '0px'}
+
+			@editor.on "nativecontextmenu", (e) =>
+				@closeContextMenu(e.domEvent)
+				@openContextMenu(e.domEvent)
+
+			$(document).on "click", (e) =>
+				@closeContextMenu(e)
+				return true
+			# $(document).on "contextmenu", (e) =>
+			# 	@closeContextMenu(e)
+
+			@$scope.replaceWord = (highlight, suggestion) =>
+				@replaceWord(highlight, suggestion)
+
+			@$scope.learnWord = (highlight) =>
+				@learnWord(highlight)
+
+		runFullCheck: () ->
+			console.log "Running full check"
+			@highlightedWordManager.clearRows()
+			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
+				@runSpellCheck()
+
+		runCheckOnChange: (e) ->
+			console.log "Checking change", e.data
+			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
+				@highlightedWordManager.applyChange(e.data)
+				@markLinesAsUpdated(e.data)
+				@runSpellCheckSoon()
+
+		openContextMenu: (e) ->
+			position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
+			highlight = @highlightedWordManager.findHighlightWithinRange
+				start: position
+				end:   position
+
+			@$scope.$apply () =>
+				@$scope.spellingMenu.highlight = highlight
+
+			console.log "highlight", @$scope.highlight_under_mouse
+
+			if highlight
+				e.stopPropagation()
+				e.preventDefault()
+
+				@editor.getSession().getSelection().setSelectionRange(
+					new Range(
+						highlight.row, highlight.column
+						highlight.row, highlight.column + highlight.word.length
+					)
+				)
+
+				console.log "Height", @element.find(".context-menu").height()
+
+				@$scope.$apply () =>
+					@$scope.spellingMenu.open = true
+					@$scope.spellingMenu.left = e.clientX + 'px'
+					@$scope.spellingMenu.top = e.clientY + 'px'
+
+		closeContextMenu: (e) ->
+			@$scope.$apply () =>
+				@$scope.spellingMenu.open = false
+
+		replaceWord: (highlight, text) ->
+			@editor.getSession().replace(new Range(
+				highlight.row, highlight.column,
+				highlight.row, highlight.column + highlight.word.length
+			), text)
+
+		learnWord: (highlight) ->
+			@apiRequest "/learn", word: highlight.word
+			@highlightedWordManager.removeWord highlight.word
+
+		getHighlightedWordAtCursor: () ->
+			cursor = @editor.getCursorPosition()
+			highlight = @highlightedWordManager.findHighlightWithinRange
+				start: cursor
+				end: cursor
+			return highlight
+
+		runSpellCheckSoon: () ->
+			run = () =>
+				delete @timeoutId
+				@runSpellCheck(@updatedLines)
+				@updatedLines = []
+			if @timeoutId?
+				clearTimeout @timeoutId
+			@timeoutId = setTimeout run, 1000
+
+		markLinesAsUpdated: (change) ->
+			start = change.range.start
+			end = change.range.end
+
+			insertLines = () =>
+				lines = end.row - start.row
+				while lines--
+					@updatedLines.splice(start.row, 0, true)
+
+			removeLines = () =>
+				lines = end.row - start.row
+				while lines--
+					@updatedLines.splice(start.row + 1, 1)
+
+			if change.action == "insertText"
+				@updatedLines[start.row] = true
+				insertLines()
+			else if change.action == "removeText"
+				@updatedLines[start.row] = true
+				removeLines()
+			else if change.action == "insertLines"
+				insertLines()
+			else if change.action == "removeLines"
+				removeLines()
+
+		runSpellCheck: (linesToProcess) ->
+			{words, positions} = @getWords(linesToProcess)
+			language = @$scope.spellCheckLanguage
+			@apiRequest "/check", {language: language, words: words}, (error, result) =>
+				if error? or !result? or !result.misspellings?
+					return null
+
+				if linesToProcess?
+					for shouldProcess, row in linesToProcess
+						@highlightedWordManager.clearRows(row, row) if shouldProcess
+				else
+					@highlightedWordManager.clearRows()
+
+				for misspelling in result.misspellings
+					word = words[misspelling.index]
+					position = positions[misspelling.index]
+					@highlightedWordManager.addHighlight
+						column: position.column
+						row: position.row
+						word: word
+						suggestions: misspelling.suggestions
+
+		getWords: (linesToProcess) ->
+			lines = @editor.getValue().split("\n")
+			words = []
+			positions = []
+			for line, row in lines
+				if !linesToProcess? or linesToProcess[row]
+					wordRegex = /\\?['a-zA-Z\u00C0-\u00FF]+/g
+					while (result = wordRegex.exec(line))
+						word = result[0]
+						if word[0] == "'"
+							word = word.slice(1)
+						if word[word.length - 1] == "'"
+							word = word.slice(0,-1)
+						positions.push row: row, column: result.index
+						words.push(word)
+			return words: words, positions: positions
+
+		apiRequest: (endpoint, data, callback = (error, result) ->)->
+			data.token = window.user.id
+			data._csrf = window.csrfToken
+			options =
+				url: "/spelling" + endpoint
+				type: "POST"
+				dataType: "json"
+				headers:
+					"Content-Type": "application/json"
+				data: JSON.stringify data
+				success: (data, status, xhr) ->
+					callback null, data
+				error: (xhr, status, error) ->
+					callback error
+			$.ajax options

+ 82 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellingMenuView.coffee

@@ -0,0 +1,82 @@
+define [
+	"libs/backbone"
+	"libs/mustache"
+], () ->
+	SUGGESTIONS_TO_SHOW = 5
+
+	SpellingMenuView = Backbone.View.extend
+		templates:
+			menu: $("#spellingMenuTemplate").html()
+			entry: $("#spellingMenuEntryTemplate").html()
+
+		events:
+			"click a#learnWord": ->
+				@trigger "click:learn", @_currentHighlight
+				@hide()
+
+		initialize: (options) ->
+			@ide = options.ide
+			@ide.editor.getContainerElement().append @render().el
+			@ide.editor.on "click", () => @hide()
+			@ide.editor.on "scroll", () => @hide()
+			@ide.editor.on "update:doc", () => @hide()
+			@ide.editor.on "change:doc", () => @hide()
+
+		render: () ->
+			@setElement($(@templates.menu))
+			@$el.css "z-index" : 10000
+			@$(".dropdown-toggle").dropdown()
+			@hide()
+			return @
+
+		showForHighlight: (highlight) ->
+			if @_currentHighlight? and highlight != @_currentHighlight
+				@_close()
+			
+			if !@_currentHighlight?
+				@_currentHighlight = highlight
+				@_setSuggestions(highlight)
+				position = @ide.editor.textToEditorCoordinates(
+					highlight.row
+					highlight.column + highlight.word.length
+				)
+				@_position(position.x, position.y)
+				@_show()
+
+		hideIfAppropriate: (cursorPosition) ->
+			if @_currentHighlight?
+				if !@_cursorCloseToHighlight(cursorPosition, @_currentHighlight) and !@_isOpen()
+					@hide()
+
+		hide: () ->
+			delete @_currentHighlight
+			@_close()
+			@$el.hide()
+
+		_setSuggestions: (highlight) ->
+			@$(".spelling-suggestion").remove()
+			divider = @$(".divider")
+			for suggestion in highlight.suggestions.slice(0, SUGGESTIONS_TO_SHOW)
+				do (suggestion) =>
+					entry = $(Mustache.to_html(@templates.entry, word: suggestion))
+					divider.before(entry)
+					entry.on "click", () =>
+						@trigger "click:suggestion", suggestion, highlight
+
+		_show: () -> @$el.show()
+
+		_isOpen: () ->
+			@$(".dropdown-menu").is(":visible")
+
+		_close: () ->
+			if @_isOpen()
+				@$el.dropdown("toggle")
+
+		_cursorCloseToHighlight: (position, highlight) ->
+			position.row == highlight.row and
+			position.column >= highlight.column and
+			position.column <= highlight.column + highlight.word.length + 1
+
+		_position: (x,y) -> @$el.css left: x, top: y
+
+

+ 374 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/undo/UndoManager.coffee

@@ -0,0 +1,374 @@
+define [
+	"ace/range"
+	"ace/edit_session"
+	"ace/document"
+], () ->
+	Range = require("ace/range").Range
+	EditSession = require("ace/edit_session").EditSession
+	Doc = require("ace/document").Document
+
+	class UndoManager
+		constructor: (@$scope, @editor) ->
+			@$scope.undo =
+				show_remote_warning: false
+				
+			@reset()
+			@nextUpdateIsRemote = false
+
+			@editor.on "changeSession", (e) =>
+				console.log "setting undo manager", e.session
+				e.session.setUndoManager(@)
+
+		showUndoConflictWarning: () ->
+			@$scope.$apply () =>
+				@$scope.undo.show_remote_warning = true
+
+			setTimeout () => 
+				@$scope.$apply () =>
+					@$scope.undo.show_remote_warning = false
+			, 4000
+
+		reset: () ->
+			@undoStack = []
+			@redoStack = []
+
+		execute: (options) ->
+			aceDeltaSets = options.args[0]
+			@session = options.args[1]
+			return if !aceDeltaSets?
+
+			lines = @session.getDocument().getAllLines()
+			linesBeforeChange = @_revertAceDeltaSetsOnDocLines(aceDeltaSets, lines)
+			simpleDeltaSets = @_aceDeltaSetsToSimpleDeltaSets(aceDeltaSets, linesBeforeChange)
+			@undoStack.push(
+				deltaSets: simpleDeltaSets
+				remote: @nextUpdateIsRemote
+			)
+			@redoStack = []
+			@nextUpdateIsRemote = false
+
+		undo: (dontSelect) ->
+			localUpdatesMade = @_shiftLocalChangeToTopOfUndoStack()
+			return if !localUpdatesMade
+
+			update = @undoStack.pop()
+			return if !update?
+
+			if update.remote
+				@showUndoConflictWarning()
+
+			lines = @session.getDocument().getAllLines()
+			linesBeforeDelta = @_revertSimpleDeltaSetsOnDocLines(update.deltaSets, lines)
+			deltaSets = @_simpleDeltaSetsToAceDeltaSets(update.deltaSets, linesBeforeDelta)
+			selectionRange = @session.undoChanges(deltaSets, dontSelect)
+			@redoStack.push(update)
+			return selectionRange
+		
+		redo: (dontSelect) ->
+			update = @redoStack.pop()
+			return if !update?
+			lines = @session.getDocument().getAllLines()
+			deltaSets = @_simpleDeltaSetsToAceDeltaSets(update.deltaSets, lines)
+			selectionRange = @session.redoChanges(deltaSets, dontSelect)
+			@undoStack.push(update)
+			return selectionRange
+
+		_shiftLocalChangeToTopOfUndoStack: () ->
+			head = []
+			localChangeExists = false
+			while @undoStack.length > 0
+				update = @undoStack.pop()
+				head.unshift update
+				if !update.remote
+					localChangeExists = true
+					break
+
+			if !localChangeExists
+				@undoStack = @undoStack.concat head
+				return false
+			else
+				# Undo stack looks like undoStack ++ reorderedhead ++ head
+				# Reordered head starts of empty and consumes entries from head
+				# while keeping the localChange at the top for as long as it can
+				localChange = head.shift()
+				reorderedHead = [localChange]
+				while head.length > 0
+					remoteChange = head.shift()
+					localChange = reorderedHead.pop()
+					result = @_swapSimpleDeltaSetsOrder(localChange.deltaSets, remoteChange.deltaSets)
+					if result?
+						remoteChange.deltaSets = result[0]
+						localChange.deltaSets = result[1]
+						reorderedHead.push remoteChange
+						reorderedHead.push localChange
+					else
+						reorderedHead.push localChange
+						reorderedHead.push remoteChange
+						break
+				@undoStack = @undoStack.concat(reorderedHead).concat(head)
+				return true
+				
+
+		_swapSimpleDeltaSetsOrder: (firstDeltaSets, secondDeltaSets) ->
+			newFirstDeltaSets = @_copyDeltaSets(firstDeltaSets)
+			newSecondDeltaSets = @_copyDeltaSets(secondDeltaSets)
+			for firstDeltaSet in newFirstDeltaSets.slice(0).reverse()
+				for firstDelta in firstDeltaSet.deltas.slice(0).reverse()
+					for secondDeltaSet in newSecondDeltaSets
+						for secondDelta in secondDeltaSet.deltas
+							success = @_swapSimpleDeltaOrderInPlace(firstDelta, secondDelta)
+							return null if !success
+			return [newSecondDeltaSets, newFirstDeltaSets]
+
+		_copyDeltaSets: (deltaSets) ->
+			newDeltaSets = []
+			for deltaSet in deltaSets
+				newDeltaSet =
+					deltas: []
+					group: deltaSet.group
+				newDeltaSets.push newDeltaSet
+				for delta in deltaSet.deltas
+					newDelta =
+						position: delta.position
+					newDelta.insert = delta.insert if delta.insert?
+					newDelta.remove = delta.remove if delta.remove?
+					newDeltaSet.deltas.push newDelta
+			return newDeltaSets
+
+		_swapSimpleDeltaOrderInPlace: (firstDelta, secondDelta) ->
+			result = @_swapSimpleDeltaOrder(firstDelta, secondDelta)
+			return false if !result?
+			firstDelta.position = result[1].position
+			secondDelta.position = result[0].position
+			return true
+
+		_swapSimpleDeltaOrder: (firstDelta, secondDelta) ->
+			if firstDelta.insert? and secondDelta.insert?
+				if secondDelta.position >= firstDelta.position + firstDelta.insert.length
+					secondDelta.position -= firstDelta.insert.length
+					return [secondDelta, firstDelta]
+				else if secondDelta.position > firstDelta.position
+					return null
+				else
+					firstDelta.position += secondDelta.insert.length
+					return [secondDelta, firstDelta]
+			else if firstDelta.remove? and secondDelta.remove?
+				if secondDelta.position >= firstDelta.position
+					secondDelta.position += firstDelta.remove.length
+					return [secondDelta, firstDelta]
+				else if secondDelta.position + secondDelta.remove.length > firstDelta.position
+					return null
+				else
+					firstDelta.position -= secondDelta.remove.length
+					return [secondDelta, firstDelta]
+			else if firstDelta.insert? and secondDelta.remove?
+				if secondDelta.position >= firstDelta.position + firstDelta.insert.length
+					secondDelta.position -= firstDelta.insert.length
+					return [secondDelta, firstDelta]
+				else if secondDelta.position + secondDelta.remove.length > firstDelta.position
+					return null
+				else
+					firstDelta.position -= secondDelta.remove.length
+					return [secondDelta, firstDelta]
+			else if firstDelta.remove? and secondDelta.insert?
+				if secondDelta.position >= firstDelta.position
+					secondDelta.position += firstDelta.remove.length
+					return [secondDelta, firstDelta]
+				else
+					firstDelta.position += secondDelta.insert.length
+					return [secondDelta, firstDelta]
+			else
+				throw "Unknown delta types"
+
+		_applyAceDeltasToDocLines: (deltas, docLines) ->
+			doc = new Doc(docLines.join("\n"))
+			doc.applyDeltas(deltas)
+			return doc.getAllLines()
+
+		_revertAceDeltaSetsOnDocLines: (deltaSets, docLines) ->
+			session = new EditSession(docLines.join("\n"))
+			session.undoChanges(deltaSets)
+			return session.getDocument().getAllLines()
+
+		_revertSimpleDeltaSetsOnDocLines: (deltaSets, docLines) ->
+			doc = docLines.join("\n")
+			for deltaSet in deltaSets.slice(0).reverse()
+				for delta in deltaSet.deltas.slice(0).reverse()
+					if delta.remove?
+						doc = doc.slice(0, delta.position) + delta.remove + doc.slice(delta.position)
+					else if delta.insert?
+						doc = doc.slice(0, delta.position) + doc.slice(delta.position + delta.insert.length)
+					else
+						throw "Unknown delta type"
+			return doc.split("\n")
+
+		_aceDeltaSetsToSimpleDeltaSets: (aceDeltaSets, docLines) ->
+			for deltaSet in aceDeltaSets
+				simpleDeltas = []
+				for delta in deltaSet.deltas
+					simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
+					docLines = @_applyAceDeltasToDocLines([delta], docLines)
+				{
+					deltas: simpleDeltas
+					group: deltaSet.group
+				}
+			
+		_simpleDeltaSetsToAceDeltaSets: (simpleDeltaSets, docLines) ->
+			for deltaSet in simpleDeltaSets
+				aceDeltas = []
+				for delta in deltaSet.deltas
+					newAceDeltas = @_simpleDeltaToAceDeltas(delta, docLines)
+					docLines = @_applyAceDeltasToDocLines(newAceDeltas, docLines)
+					aceDeltas = aceDeltas.concat newAceDeltas
+				{
+					deltas: aceDeltas
+					group: deltaSet.group
+				}
+
+		_aceDeltaToSimpleDelta: (aceDelta, docLines) ->
+			start = aceDelta.range.start
+			linesBefore = docLines.slice(0, start.row)
+			position =
+				linesBefore.join("").length + # full lines
+				linesBefore.length + # new line characters
+				start.column # partial line
+			switch aceDelta.action
+				when "insertText"
+					return {
+						position: position
+						insert: aceDelta.text
+					}
+				when "insertLines"
+					return {
+						position: position
+						insert: aceDelta.lines.join("\n") + "\n"
+					}
+				when "removeText"
+					return {
+						position: position
+						remove: aceDelta.text
+					}
+				when "removeLines"
+					return {
+						position: position
+						remove: aceDelta.lines.join("\n") + "\n"
+					}
+				else
+					throw "Unknown Ace action: #{aceDelta.action}"
+
+		_simplePositionToAcePosition: (position, docLines) ->
+			column = 0
+			row = 0
+			for line in docLines
+				if position > line.length
+					row++
+					position -= (line + "\n").length
+				else
+					column = position
+					break
+			return {row: row, column: column}
+
+		_textToAceActions: (simpleText, row, column, type) ->
+			aceDeltas = []
+			lines = simpleText.split("\n")
+
+			range = (options) -> new Range(options.start.row, options.start.column, options.end.row, options.end.column)
+
+			do stripFirstLine = () ->
+				firstLine = lines.shift()
+				if firstLine.length > 0
+					aceDeltas.push {
+						text: firstLine
+						range: range(
+							start: column: column, row: row
+							end: column: column + firstLine.length, row: row
+						)
+						action: "#{type}Text"
+					}
+					column += firstLine.length
+
+			do stripFirstNewLine = () ->
+				if lines.length > 0
+					aceDeltas.push {
+						text: "\n"
+						range: range(
+							start: column: column, row: row
+							end: column: 0, row: row + 1
+						)
+						action: "#{type}Text"
+					}
+					row += 1
+
+			do stripMiddleFullLines = () ->
+				middleLines = lines.slice(0, -1)
+				if middleLines.length > 0
+					aceDeltas.push {
+						lines: middleLines
+						range: range(
+							start: column: 0, row: row
+							end: column: 0, row: row + middleLines.length
+						)
+						action: "#{type}Lines"
+					}
+					row += middleLines.length
+
+			do stripLastLine = () ->
+				if lines.length > 0
+					lastLine = lines.pop()
+					aceDeltas.push {
+						text: lastLine
+						range: range(
+							start: column: 0, row: row
+							end: column: lastLine.length , row: row
+						)
+						action: "#{type}Text"
+					}
+
+			return aceDeltas
+
+
+		_simpleDeltaToAceDeltas: (simpleDelta, docLines) ->
+			{row, column} = @_simplePositionToAcePosition(simpleDelta.position, docLines)
+
+			if simpleDelta.insert?
+				return @_textToAceActions(simpleDelta.insert, row, column, "insert")
+			if simpleDelta.remove?
+				return @_textToAceActions(simpleDelta.remove, row, column, "remove").reverse()
+			else
+				throw "Unknown simple delta: #{simpleDelta}"
+
+		_concatSimpleDeltas: (deltas) ->
+			return [] if deltas.length == 0
+
+			concattedDeltas = []
+			previousDelta = deltas.shift()
+			for delta in deltas
+				if delta.insert? and previousDelta.insert?
+					if previousDelta.position + previousDelta.insert.length == delta.position
+						previousDelta =
+							insert: previousDelta.insert + delta.insert
+							position: previousDelta.position
+					else
+						concattedDeltas.push previousDelta
+						previousDelta = delta
+
+				else if delta.remove? and previousDelta.remove?
+					if previousDelta.position == delta.position
+						previousDelta =
+							remove: previousDelta.remove + delta.remove
+							position: delta.position
+					else
+						concattedDeltas.push previousDelta
+						previousDelta = delta
+				else
+					concattedDeltas.push previousDelta
+					previousDelta = delta
+			concattedDeltas.push previousDelta
+					
+
+			return concattedDeltas
+				
+
+		hasUndo: () -> @undoStack.length > 0
+		hasRedo: () -> @redoStack.length > 0
+		

+ 3 - 0
services/web/public/coffee/ide/editor/sharejs/header.coffee

@@ -0,0 +1,3 @@
+WEB = true
+window.sharejs = exports = {}
+types = exports.types = {}

+ 128 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/ace.coffee

@@ -0,0 +1,128 @@
+# This is some utility code to connect an ace editor to a sharejs document.
+
+Range = require("ace/range").Range
+
+# Convert an ace delta into an op understood by share.js
+applyToShareJS = (editorDoc, delta, doc) ->
+  # Get the start position of the range, in no. of characters
+  getStartOffsetPosition = (range) ->
+    # This is quite inefficient - getLines makes a copy of the entire
+    # lines array in the document. It would be nice if we could just
+    # access them directly.
+    lines = editorDoc.getLines 0, range.start.row
+      
+    offset = 0
+
+    for line, i in lines
+      offset += if i < range.start.row
+        line.length
+      else
+        range.start.column
+
+    # Add the row number to include newlines.
+    offset + range.start.row
+
+  pos = getStartOffsetPosition(delta.range)
+
+  switch delta.action
+    when 'insertText' then doc.insert pos, delta.text
+    when 'removeText' then doc.del pos, delta.text.length
+    
+    when 'insertLines'
+      text = delta.lines.join('\n') + '\n'
+      doc.insert pos, text
+      
+    when 'removeLines'
+      text = delta.lines.join('\n') + '\n'
+      doc.del pos, text.length
+
+    else throw new Error "unknown action: #{delta.action}"
+  
+  return
+
+# Attach an ace editor to the document. The editor's contents are replaced
+# with the document's contents unless keepEditorContents is true. (In which case the document's
+# contents are nuked and replaced with the editor's).
+window.sharejs.extendDoc 'attach_ace', (editor, keepEditorContents) ->
+  throw new Error 'Only text documents can be attached to ace' unless @provides['text']
+
+  doc = this
+  editorDoc = editor.getSession().getDocument()
+  editorDoc.setNewLineMode 'unix'
+
+  check = ->
+    window.setTimeout ->
+        editorText = editorDoc.getValue()
+        otText = doc.getText()
+
+        if editorText != otText
+          console.error "Text does not match!"
+          console.error "editor: #{editorText}"
+          console.error "ot:     #{otText}"
+          # Should probably also replace the editor text with the doc snapshot.
+      , 0
+
+  if keepEditorContents
+    doc.del 0, doc.getText().length
+    doc.insert 0, editorDoc.getValue()
+  else
+    editorDoc.setValue doc.getText()
+
+  check()
+
+  # When we apply ops from sharejs, ace emits edit events. We need to ignore those
+  # to prevent an infinite typing loop.
+  suppress = false
+  
+  # Listen for edits in ace
+  editorListener = (change) ->
+    return if suppress
+    applyToShareJS editorDoc, change.data, doc
+
+    check()
+
+  editorDoc.on 'change', editorListener
+
+  # Listen for remote ops on the sharejs document
+  docListener = (op) ->
+    suppress = true
+    applyToDoc editorDoc, op
+    suppress = false
+
+    check()
+
+
+  # Horribly inefficient.
+  offsetToPos = (offset) ->
+    # Again, very inefficient.
+    lines = editorDoc.getAllLines()
+
+    row = 0
+    for line, row in lines
+      break if offset <= line.length
+
+      # +1 for the newline.
+      offset -= lines[row].length + 1
+
+    row:row, column:offset
+
+  doc.on 'insert', (pos, text) ->
+    suppress = true
+    editorDoc.insert offsetToPos(pos), text
+    suppress = false
+    check()
+
+  doc.on 'delete', (pos, text) ->
+    suppress = true
+    range = Range.fromPoints offsetToPos(pos), offsetToPos(pos + text.length)
+    editorDoc.remove range
+    suppress = false
+    check()
+
+  doc.detach_ace = ->
+    doc.removeListener 'remoteop', docListener
+    editorDoc.removeListener 'change', editorListener
+    delete doc.detach_ace
+
+  return
+

+ 119 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/ace.js

@@ -0,0 +1,119 @@
+// Generated by CoffeeScript 1.4.0
+(function() {
+  var Range, applyToShareJS;
+
+  Range = require("ace/range").Range;
+
+  applyToShareJS = function(editorDoc, delta, doc) {
+    var getStartOffsetPosition, pos, text;
+    getStartOffsetPosition = function(range) {
+      var i, line, lines, offset, _i, _len;
+      lines = editorDoc.getLines(0, range.start.row);
+      offset = 0;
+      for (i = _i = 0, _len = lines.length; _i < _len; i = ++_i) {
+        line = lines[i];
+        offset += i < range.start.row ? line.length : range.start.column;
+      }
+      return offset + range.start.row;
+    };
+    pos = getStartOffsetPosition(delta.range);
+    switch (delta.action) {
+      case 'insertText':
+        doc.insert(pos, delta.text);
+        break;
+      case 'removeText':
+        doc.del(pos, delta.text.length);
+        break;
+      case 'insertLines':
+        text = delta.lines.join('\n') + '\n';
+        doc.insert(pos, text);
+        break;
+      case 'removeLines':
+        text = delta.lines.join('\n') + '\n';
+        doc.del(pos, text.length);
+        break;
+      default:
+        throw new Error("unknown action: " + delta.action);
+    }
+  };
+
+  window.sharejs.extendDoc('attach_ace', function(editor, keepEditorContents) {
+    var check, doc, docListener, editorDoc, editorListener, offsetToPos, suppress;
+    if (!this.provides['text']) {
+      throw new Error('Only text documents can be attached to ace');
+    }
+    doc = this;
+    editorDoc = editor.getSession().getDocument();
+    editorDoc.setNewLineMode('unix');
+    check = function() {
+      return window.setTimeout(function() {
+        var editorText, otText;
+        editorText = editorDoc.getValue();
+        otText = doc.getText();
+        if (editorText !== otText) {
+          console.error("Text does not match!");
+          console.error("editor: " + editorText);
+          return console.error("ot:     " + otText);
+        }
+      }, 0);
+    };
+    if (keepEditorContents) {
+      doc.del(0, doc.getText().length);
+      doc.insert(0, editorDoc.getValue());
+    } else {
+      editorDoc.setValue(doc.getText());
+    }
+    check();
+    suppress = false;
+    editorListener = function(change) {
+      if (suppress) {
+        return;
+      }
+      applyToShareJS(editorDoc, change.data, doc);
+      return check();
+    };
+    editorDoc.on('change', editorListener);
+    docListener = function(op) {
+      suppress = true;
+      applyToDoc(editorDoc, op);
+      suppress = false;
+      return check();
+    };
+    offsetToPos = function(offset) {
+      var line, lines, row, _i, _len;
+      lines = editorDoc.getAllLines();
+      row = 0;
+      for (row = _i = 0, _len = lines.length; _i < _len; row = ++_i) {
+        line = lines[row];
+        if (offset <= line.length) {
+          break;
+        }
+        offset -= lines[row].length + 1;
+      }
+      return {
+        row: row,
+        column: offset
+      };
+    };
+    doc.on('insert', function(pos, text) {
+      suppress = true;
+      editorDoc.insert(offsetToPos(pos), text);
+      suppress = false;
+      return check();
+    });
+    doc.on('delete', function(pos, text) {
+      var range;
+      suppress = true;
+      range = Range.fromPoints(offsetToPos(pos), offsetToPos(pos + text.length));
+      editorDoc.remove(range);
+      suppress = false;
+      return check();
+    });
+    doc.detach_ace = function() {
+      doc.removeListener('remoteop', docListener);
+      editorDoc.removeListener('change', editorListener);
+      return delete doc.detach_ace;
+    };
+  });
+
+}).call(this);

+ 94 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/cm.coffee

@@ -0,0 +1,94 @@
+# This is some utility code to connect a CodeMirror editor
+# to a sharejs document.
+# It is heavily inspired from the Ace editor hook.
+
+# Convert a CodeMirror delta into an op understood by share.js
+applyToShareJS = (editorDoc, delta, doc) ->
+  # CodeMirror deltas give a text replacement.
+  # I tuned this operation a little bit, for speed.
+  startPos = 0  # Get character position from # of chars in each line.
+  i = 0         # i goes through all lines.
+
+  while i < delta.from.line
+    startPos += editorDoc.lineInfo(i).text.length + 1   # Add 1 for '\n'
+    i++
+
+  startPos += delta.from.ch
+
+  if delta.to.line == delta.from.line &&
+     delta.to.ch == delta.from.ch # Then nothing was removed.
+    doc.insert startPos, delta.text.join '\n'
+  else
+    delLen = delta.to.ch - delta.from.ch
+    while i < delta.to.line
+      delLen += editorDoc.lineInfo(i).text.length + 1   # Add 1 for '\n'
+      i++
+    doc.del startPos, delLen
+    doc.insert startPos, delta.text.join '\n' if delta.text
+
+  applyToShareJS editorDoc, delta.next, doc if delta.next
+
+# Attach a CodeMirror editor to the document. The editor's contents are replaced
+# with the document's contents unless keepEditorContents is true. (In which case
+# the document's contents are nuked and replaced with the editor's).
+window.sharejs.extendDoc 'attach_cm', (editor, keepEditorContents) ->
+  unless @provides.text
+    throw new Error 'Only text documents can be attached to CodeMirror2'
+
+  sharedoc = @
+  check = ->
+    window.setTimeout ->
+        editorText = editor.getValue()
+        otText = sharedoc.getValue()
+
+        if editorText != otText
+          console.error "Text does not match!"
+          console.error "editor: #{editorText}"
+          console.error "ot:     #{otText}"
+          # Replace the editor text with the doc snapshot.
+          editor.setValue sharedoc.getValue()
+      , 0
+
+  if keepEditorContents
+    @del 0, sharedoc.getText().length
+    @insert 0, editor.getValue()
+  else
+    editor.setValue sharedoc.getText()
+
+  check()
+
+  # When we apply ops from sharejs, CodeMirror emits edit events.
+  # We need to ignore those to prevent an infinite typing loop.
+  suppress = false
+
+  # Listen for edits in CodeMirror.
+  editorListener = (ed, change) ->
+    return if suppress
+    applyToShareJS editor, change, sharedoc
+
+    check()
+
+  editor.setOption 'onChange',  editorListener
+
+  @on 'insert', (pos, text) ->
+    suppress = true
+    # All the primitives we need are already in CM's API.
+    editor.replaceRange text, editor.posFromIndex(pos)
+    suppress = false
+    check()
+
+  @on 'delete', (pos, text) ->
+    suppress = true
+    from = editor.posFromIndex pos
+    to = editor.posFromIndex (pos + text.length)
+    editor.replaceRange '', from, to
+    suppress = false
+    check()
+
+  @detach_cm = ->
+    # TODO: can we remove the insert and delete event callbacks?
+    editor.setOption 'onChange', null
+    delete @detach_cm
+
+  return
+

+ 167 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/connection.coffee

@@ -0,0 +1,167 @@
+# A Connection wraps a persistant BC connection to a sharejs server.
+#
+# This class implements the client side of the protocol defined here:
+# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
+#
+# The equivalent server code is in src/server/browserchannel.coffee.
+#
+# This file is a bit of a mess. I'm dreadfully sorry about that. It passes all the tests,
+# so I have hope that its *correct* even if its not clean.
+#
+# Most of Connection exists to support the open() method, which creates a new document
+# reference.
+
+if WEB?
+  types = exports.types
+  throw new Error 'Must load browserchannel before this library' unless window.BCSocket
+  {BCSocket} = window
+else
+  types = require '../types'
+  {BCSocket} = require 'browserchannel'
+  Doc = require('./doc').Doc
+
+class Connection
+  constructor: (host) ->
+    # Map of docname -> doc
+    @docs = {}
+
+    # States:
+    # - 'connecting': The connection is being established
+    # - 'handshaking': The connection has been established, but we don't have the auth ID yet
+    # - 'ok': We have connected and recieved our client ID. Ready for data.
+    # - 'disconnected': The connection is closed, but it will not reconnect automatically.
+    # - 'stopped': The connection is closed, and will not reconnect.
+    @state = 'connecting'
+    @socket = new BCSocket host, reconnect:true
+
+    @socket.onmessage = (msg) =>
+      if msg.auth is null
+        # Auth failed.
+        @lastError = msg.error # 'forbidden'
+        @disconnect()
+        return @emit 'connect failed', msg.error
+      else if msg.auth
+        # Our very own client id.
+        @id = msg.auth
+        @setState 'ok'
+        return
+
+      docName = msg.doc
+
+      if docName isnt undefined
+        @lastReceivedDoc = docName
+      else
+        msg.doc = docName = @lastReceivedDoc
+
+      if @docs[docName]
+        @docs[docName]._onMessage msg
+      else
+        console?.error 'Unhandled message', msg
+
+    @connected = false
+    @socket.onclose = (reason) =>
+      #console.warn 'onclose', reason
+      @setState 'disconnected', reason
+      if reason in ['Closed', 'Stopped by server']
+        @setState 'stopped', @lastError or reason
+
+    @socket.onerror = (e) =>
+      #console.warn 'onerror', e
+      @emit 'error', e
+
+    @socket.onopen = =>
+      #console.warn 'onopen'
+      @lastError = @lastReceivedDoc = @lastSentDoc = null
+      @setState 'handshaking'
+
+    @socket.onconnecting = =>
+      #console.warn 'connecting'
+      @setState 'connecting'
+
+  setState: (state, data) ->
+    return if @state is state
+    @state = state
+
+    delete @id if state is 'disconnected'
+    @emit state, data
+
+    # Documents could just subscribe to the state change events, but there's less state to
+    # clean up when you close a document if I just notify the doucments directly.
+    for docName, doc of @docs
+      doc._connectionStateChanged state, data
+
+  send: (data) ->
+    docName = data.doc
+
+    if docName is @lastSentDoc
+      delete data.doc
+    else
+      @lastSentDoc = docName
+
+    #console.warn 'c->s', data
+    @socket.send data
+
+  disconnect: ->
+    # This will call @socket.onclose(), which in turn will emit the 'disconnected' event.
+    #console.warn 'calling close on the socket'
+    @socket.close()
+
+  # *** Doc management
+ 
+  makeDoc: (name, data, callback) ->
+    throw new Error("Doc #{name} already open") if @docs[name]
+    doc = new Doc(@, name, data)
+    @docs[name] = doc
+
+    doc.open (error) =>
+      delete @docs[name] if error
+      callback error, (doc unless error)
+
+  # Open a document that already exists
+  # callback(error, doc)
+  openExisting: (docName, callback) ->
+    return callback 'connection closed' if @state is 'stopped'
+    return callback null, @docs[docName] if @docs[docName]
+    doc = @makeDoc docName, {}, callback
+
+  # Open a document. It will be created if it doesn't already exist.
+  # Callback is passed a document or an error
+  # type is either a type name (eg 'text' or 'simple') or the actual type object.
+  # Types must be supported by the server.
+  # callback(error, doc)
+  open: (docName, type, callback) ->
+    return callback 'connection closed' if @state is 'stopped'
+
+    if typeof type is 'function'
+      callback = type
+      type = 'text'
+
+    callback ||= ->
+
+    type = types[type] if typeof type is 'string'
+
+    throw new Error "OT code for document type missing" unless type
+
+    throw new Error 'Server-generated random doc names are not currently supported' unless docName?
+
+    if @docs[docName]
+      doc = @docs[docName]
+      if doc.type == type
+        callback null, doc
+      else
+        callback 'Type mismatch', doc
+      return
+
+    @makeDoc docName, {create:true, type:type.name}, callback
+
+# Not currently working.
+#  create: (type, callback) ->
+#    open null, type, callback
+
+# Make connections event emitters.
+unless WEB?
+  MicroEvent = require './microevent'
+
+MicroEvent.mixin Connection
+
+exports.Connection = Connection

+ 327 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.coffee

@@ -0,0 +1,327 @@
+unless WEB?
+  types = require '../types'
+
+if WEB?
+  exports.extendDoc = (name, fn) ->
+    Doc::[name] = fn
+
+# A Doc is a client's view on a sharejs document.
+#
+# Documents are created by calling Connection.open().
+#
+# Documents are event emitters - use doc.on(eventname, fn) to subscribe.
+#
+# Documents get mixed in with their type's API methods. So, you can .insert('foo', 0) into
+# a text document and stuff like that.
+#
+# Events:
+#  - remoteop (op)
+#  - changed (op)
+#  - acknowledge (op)
+#  - error
+#  - open, closing, closed. 'closing' is not guaranteed to fire before closed.
+class Doc
+  # connection is a Connection object.
+  # name is the documents' docName.
+  # data can optionally contain known document data, and initial open() call arguments:
+  # {v[erson], snapshot={...}, type, create=true/false/undefined}
+  # callback will be called once the document is first opened.
+  constructor: (@connection, @name, openData) ->
+    # Any of these can be null / undefined at this stage.
+    openData ||= {}
+    @version = openData.v
+    @snapshot = openData.snaphot
+    @_setType openData.type if openData.type
+
+    @state = 'closed'
+    @autoOpen = false
+
+    # Has the document already been created?
+    @_create = openData.create
+
+    # The op that is currently roundtripping to the server, or null.
+    #
+    # When the connection reconnects, the inflight op is resubmitted.
+    @inflightOp = null
+    @inflightCallbacks = []
+    # The auth ids which the client has previously used to attempt to send inflightOp. This is
+    # usually empty.
+    @inflightSubmittedIds = []
+
+    # All ops that are waiting for the server to acknowledge @inflightOp
+    @pendingOp = null
+    @pendingCallbacks = []
+
+    # Some recent ops, incase submitOp is called with an old op version number.
+    @serverOps = {}
+
+  # Transform a server op by a client op, and vice versa.
+  _xf: (client, server) ->
+    if @type.transformX
+      @type.transformX(client, server)
+    else
+      client_ = @type.transform client, server, 'left'
+      server_ = @type.transform server, client, 'right'
+      return [client_, server_]
+  
+  _otApply: (docOp, isRemote) ->
+    oldSnapshot = @snapshot
+    @snapshot = @type.apply(@snapshot, docOp)
+
+    # Its important that these event handlers are called with oldSnapshot.
+    # The reason is that the OT type APIs might need to access the snapshots to
+    # determine information about the received op.
+    @emit 'change', docOp, oldSnapshot
+    @emit 'remoteop', docOp, oldSnapshot if isRemote
+  
+  _connectionStateChanged: (state, data) ->
+    switch state
+      when 'disconnected'
+        @state = 'closed'
+        # This is used by the server to make sure that when an op is resubmitted it
+        # doesn't end up getting applied twice.
+        @inflightSubmittedIds.push @connection.id if @inflightOp
+
+        @emit 'closed'
+
+      when 'ok' # Might be able to do this when we're connecting... that would save a roundtrip.
+        @open() if @autoOpen
+
+      when 'stopped'
+        @_openCallback? data
+
+    @emit state, data
+
+  _setType: (type) ->
+    if typeof type is 'string'
+      type = types[type]
+
+    throw new Error 'Support for types without compose() is not implemented' unless type and type.compose
+
+    @type = type
+    if type.api
+      this[k] = v for k, v of type.api
+      @_register?()
+    else
+      @provides = {}
+
+  _onMessage: (msg) ->
+    #console.warn 's->c', msg
+    if msg.open == true
+      # The document has been successfully opened.
+      @state = 'open'
+      @_create = false # Don't try and create the document again next time open() is called.
+      unless @created?
+        @created = !!msg.create
+
+      @_setType msg.type if msg.type
+      if msg.create
+        @created = true
+        @snapshot = @type.create()
+      else
+        @created = false unless @created is true
+        @snapshot = msg.snapshot if msg.snapshot isnt undefined
+
+      @version = msg.v if msg.v?
+
+      # Resend any previously queued operation.
+      if @inflightOp
+        response =
+          doc: @name
+          op: @inflightOp
+          v: @version
+        response.dupIfSource = @inflightSubmittedIds if @inflightSubmittedIds.length
+        @connection.send response
+      else
+        @flush()
+
+      @emit 'open'
+      
+      @_openCallback? null
+ 
+    else if msg.open == false
+      # The document has either been closed, or an open request has failed.
+      if msg.error
+        # An error occurred opening the document.
+        console?.error "Could not open document: #{msg.error}"
+        @emit 'error', msg.error
+        @_openCallback? msg.error
+
+      @state = 'closed'
+      @emit 'closed'
+
+      @_closeCallback?()
+      @_closeCallback = null
+
+    else if msg.op is null and error is 'Op already submitted'
+      # We've tried to resend an op to the server, which has already been received successfully. Do nothing.
+      # The op will be confirmed normally when we get the op itself was echoed back from the server
+      # (handled below).
+
+    else if (msg.op is undefined and msg.v isnt undefined) or (msg.op and msg.meta.source in @inflightSubmittedIds)
+      # Our inflight op has been acknowledged.
+      oldInflightOp = @inflightOp
+      @inflightOp = null
+      @inflightSubmittedIds.length = 0
+
+      error = msg.error
+      if error
+        # The server has rejected an op from the client for some reason.
+        # We'll send the error message to the user and roll back the change.
+        #
+        # If the server isn't going to allow edits anyway, we should probably
+        # figure out some way to flag that (readonly:true in the open request?)
+
+        if @type.invert
+          undo = @type.invert oldInflightOp
+
+          # Now we have to transform the undo operation by any server ops & pending ops
+          if @pendingOp
+            [@pendingOp, undo] = @_xf @pendingOp, undo
+
+          # ... and apply it locally, reverting the changes.
+          # 
+          # This call will also call @emit 'remoteop'. I'm still not 100% sure about this
+          # functionality, because its really a local op. Basically, the problem is that
+          # if the client's op is rejected by the server, the editor window should update
+          # to reflect the undo.
+          @_otApply undo, true
+        else
+          @emit 'error', "Op apply failed (#{error}) and the op could not be reverted"
+
+        callback error for callback in @inflightCallbacks
+      else
+        # The op applied successfully.
+        throw new Error('Invalid version from server') unless msg.v == @version
+
+        @serverOps[@version] = oldInflightOp
+        @version++
+        @emit 'acknowledge', oldInflightOp
+        callback null, oldInflightOp for callback in @inflightCallbacks
+
+      # Send the next op.
+      @flush()
+
+    else if msg.op
+      # We got a new op from the server.
+      # msg is {doc:, op:, v:}
+
+      # There is a bug in socket.io (produced on firefox 3.6) which causes messages
+      # to be duplicated sometimes.
+      # We'll just silently drop subsequent messages.
+      return if msg.v < @version
+
+      return @emit 'error', "Expected docName '#{@name}' but got #{msg.doc}" unless msg.doc == @name
+      return @emit 'error', "Expected version #{@version} but got #{msg.v}" unless msg.v == @version
+
+  #    p "if: #{i @inflightOp} pending: #{i @pendingOp} doc '#{@snapshot}' op: #{i msg.op}"
+
+      op = msg.op
+      @serverOps[@version] = op
+
+      docOp = op
+      if @inflightOp != null
+        [@inflightOp, docOp] = @_xf @inflightOp, docOp
+      if @pendingOp != null
+        [@pendingOp, docOp] = @_xf @pendingOp, docOp
+        
+      @version++
+      # Finally, apply the op to @snapshot and trigger any event listeners
+      @_otApply docOp, true
+
+    else if msg.meta
+      {path, value} = msg.meta
+
+      switch path?[0]
+        when 'shout'
+          return @emit 'shout', value
+        else
+          console?.warn 'Unhandled meta op:', msg
+
+    else
+      console?.warn 'Unhandled document message:', msg
+
+
+  # Send ops to the server, if appropriate.
+  #
+  # Only one op can be in-flight at a time, so if an op is already on its way then
+  # this method does nothing.
+  flush: =>
+    return unless @connection.state == 'ok' and @inflightOp == null and @pendingOp != null
+
+    # Rotate null -> pending -> inflight
+    @inflightOp = @pendingOp
+    @inflightCallbacks = @pendingCallbacks
+
+    @pendingOp = null
+    @pendingCallbacks = []
+
+    @connection.send {doc:@name, op:@inflightOp, v:@version}
+
+  # Submit an op to the server. The op maybe held for a little while before being sent, as only one
+  # op can be inflight at any time.
+  submitOp: (op, callback) ->
+    op = @type.normalize(op) if @type.normalize?
+
+    # If this throws an exception, no changes should have been made to the doc
+    @snapshot = @type.apply @snapshot, op
+
+    if @pendingOp != null
+      @pendingOp = @type.compose(@pendingOp, op)
+    else
+      @pendingOp = op
+
+    @pendingCallbacks.push callback if callback
+
+    @emit 'change', op
+
+    # A timeout is used so if the user sends multiple ops at the same time, they'll be composed
+    # & sent together.
+    setTimeout @flush, 0
+  
+  shout: (msg) =>
+    # Meta ops don't have to queue, they can go direct. Good/bad idea?
+    @connection.send {doc:@name, meta: { path: ['shout'], value: msg } }
+  
+  # Open a document. The document starts closed.
+  open: (callback) ->
+    @autoOpen = true
+    return unless @state is 'closed'
+
+    message =
+      doc: @name
+      open: true
+
+    message.snapshot = null if @snapshot is undefined
+    message.type = @type.name if @type
+    message.v = @version if @version?
+    message.create = true if @_create
+
+    @connection.send message
+
+    @state = 'opening'
+
+    @_openCallback = (error) =>
+      @_openCallback = null
+      callback? error
+
+  # Close a document.
+  close: (callback) ->
+    @autoOpen = false
+    return callback?() if @state is 'closed'
+
+    @connection.send {doc:@name, open:false}
+
+    # Should this happen immediately or when we get open:false back from the server?
+    @state = 'closed'
+
+    @emit 'closing'
+    @_closeCallback = callback
+ 
+# Make documents event emitters
+unless WEB?
+  MicroEvent = require './microevent'
+
+MicroEvent.mixin Doc
+
+exports.Doc = Doc

+ 332 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/doc.js

@@ -0,0 +1,332 @@
+// Generated by CoffeeScript 1.4.0
+(function() {
+  var Doc, MicroEvent, types,
+    __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
+    __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
+
+  if (typeof WEB === "undefined" || WEB === null) {
+    types = require('../types');
+  }
+
+  if (typeof WEB !== "undefined" && WEB !== null) {
+    exports.extendDoc = function(name, fn) {
+      return Doc.prototype[name] = fn;
+    };
+  }
+
+  Doc = (function() {
+
+    function Doc(connection, name, openData) {
+      this.connection = connection;
+      this.name = name;
+      this.shout = __bind(this.shout, this);
+
+      this.flush = __bind(this.flush, this);
+
+      openData || (openData = {});
+      this.version = openData.v;
+      this.snapshot = openData.snaphot;
+      if (openData.type) {
+        this._setType(openData.type);
+      }
+      this.state = 'closed';
+      this.autoOpen = false;
+      this._create = openData.create;
+      this.inflightOp = null;
+      this.inflightCallbacks = [];
+      this.inflightSubmittedIds = [];
+      this.pendingOp = null;
+      this.pendingCallbacks = [];
+      this.serverOps = {};
+    }
+
+    Doc.prototype._xf = function(client, server) {
+      var client_, server_;
+      if (this.type.transformX) {
+        return this.type.transformX(client, server);
+      } else {
+        client_ = this.type.transform(client, server, 'left');
+        server_ = this.type.transform(server, client, 'right');
+        return [client_, server_];
+      }
+    };
+
+    Doc.prototype._otApply = function(docOp, isRemote) {
+      var oldSnapshot;
+      oldSnapshot = this.snapshot;
+      this.snapshot = this.type.apply(this.snapshot, docOp);
+      this.emit('change', docOp, oldSnapshot);
+      if (isRemote) {
+        return this.emit('remoteop', docOp, oldSnapshot);
+      }
+    };
+
+    Doc.prototype._connectionStateChanged = function(state, data) {
+      switch (state) {
+        case 'disconnected':
+          this.state = 'closed';
+          if (this.inflightOp) {
+            this.inflightSubmittedIds.push(this.connection.id);
+          }
+          this.emit('closed');
+          break;
+        case 'ok':
+          if (this.autoOpen) {
+            this.open();
+          }
+          break;
+        case 'stopped':
+          if (typeof this._openCallback === "function") {
+            this._openCallback(data);
+          }
+      }
+      return this.emit(state, data);
+    };
+
+    Doc.prototype._setType = function(type) {
+      var k, v, _ref;
+      if (typeof type === 'string') {
+        type = types[type];
+      }
+      if (!(type && type.compose)) {
+        throw new Error('Support for types without compose() is not implemented');
+      }
+      this.type = type;
+      if (type.api) {
+        _ref = type.api;
+        for (k in _ref) {
+          v = _ref[k];
+          this[k] = v;
+        }
+        return typeof this._register === "function" ? this._register() : void 0;
+      } else {
+        return this.provides = {};
+      }
+    };
+
+    Doc.prototype._onMessage = function(msg) {
+      var callback, docOp, error, oldInflightOp, op, path, response, undo, value, _i, _j, _len, _len1, _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6;
+      if (msg.open === true) {
+        this.state = 'open';
+        this._create = false;
+        if (this.created == null) {
+          this.created = !!msg.create;
+        }
+        if (msg.type) {
+          this._setType(msg.type);
+        }
+        if (msg.create) {
+          this.created = true;
+          this.snapshot = this.type.create();
+        } else {
+          if (this.created !== true) {
+            this.created = false;
+          }
+          if (msg.snapshot !== void 0) {
+            this.snapshot = msg.snapshot;
+          }
+        }
+        if (msg.v != null) {
+          this.version = msg.v;
+        }
+        if (this.inflightOp) {
+          response = {
+            doc: this.name,
+            op: this.inflightOp,
+            v: this.version
+          };
+          if (this.inflightSubmittedIds.length) {
+            response.dupIfSource = this.inflightSubmittedIds;
+          }
+          this.connection.send(response);
+        } else {
+          this.flush();
+        }
+        this.emit('open');
+        return typeof this._openCallback === "function" ? this._openCallback(null) : void 0;
+      } else if (msg.open === false) {
+        if (msg.error) {
+          if (typeof console !== "undefined" && console !== null) {
+            console.error("Could not open document: " + msg.error);
+          }
+          this.emit('error', msg.error);
+          if (typeof this._openCallback === "function") {
+            this._openCallback(msg.error);
+          }
+        }
+        this.state = 'closed';
+        this.emit('closed');
+        if (typeof this._closeCallback === "function") {
+          this._closeCallback();
+        }
+        return this._closeCallback = null;
+      } else if (msg.op === null && error === 'Op already submitted') {
+
+      } else if ((msg.op === void 0 && msg.v !== void 0) || (msg.op && (_ref = msg.meta.source, __indexOf.call(this.inflightSubmittedIds, _ref) >= 0))) {
+        oldInflightOp = this.inflightOp;
+        this.inflightOp = null;
+        this.inflightSubmittedIds.length = 0;
+        error = msg.error;
+        if (error) {
+          if (this.type.invert) {
+            undo = this.type.invert(oldInflightOp);
+            if (this.pendingOp) {
+              _ref1 = this._xf(this.pendingOp, undo), this.pendingOp = _ref1[0], undo = _ref1[1];
+            }
+            this._otApply(undo, true);
+          } else {
+            this.emit('error', "Op apply failed (" + error + ") and the op could not be reverted");
+          }
+          _ref2 = this.inflightCallbacks;
+          for (_i = 0, _len = _ref2.length; _i < _len; _i++) {
+            callback = _ref2[_i];
+            callback(error);
+          }
+        } else {
+          if (msg.v !== this.version) {
+            throw new Error('Invalid version from server');
+          }
+          this.serverOps[this.version] = oldInflightOp;
+          this.version++;
+          this.emit('acknowledge', oldInflightOp);
+          _ref3 = this.inflightCallbacks;
+          for (_j = 0, _len1 = _ref3.length; _j < _len1; _j++) {
+            callback = _ref3[_j];
+            callback(null, oldInflightOp);
+          }
+        }
+        return this.flush();
+      } else if (msg.op) {
+        if (msg.v < this.version) {
+          return;
+        }
+        if (msg.doc !== this.name) {
+          return this.emit('error', "Expected docName '" + this.name + "' but got " + msg.doc);
+        }
+        if (msg.v !== this.version) {
+          return this.emit('error', "Expected version " + this.version + " but got " + msg.v);
+        }
+        op = msg.op;
+        this.serverOps[this.version] = op;
+        docOp = op;
+        if (this.inflightOp !== null) {
+          _ref4 = this._xf(this.inflightOp, docOp), this.inflightOp = _ref4[0], docOp = _ref4[1];
+        }
+        if (this.pendingOp !== null) {
+          _ref5 = this._xf(this.pendingOp, docOp), this.pendingOp = _ref5[0], docOp = _ref5[1];
+        }
+        this.version++;
+        return this._otApply(docOp, true);
+      } else if (msg.meta) {
+        _ref6 = msg.meta, path = _ref6.path, value = _ref6.value;
+        switch (path != null ? path[0] : void 0) {
+          case 'shout':
+            return this.emit('shout', value);
+          default:
+            return typeof console !== "undefined" && console !== null ? console.warn('Unhandled meta op:', msg) : void 0;
+        }
+      } else {
+        return typeof console !== "undefined" && console !== null ? console.warn('Unhandled document message:', msg) : void 0;
+      }
+    };
+
+    Doc.prototype.flush = function() {
+      if (!(this.connection.state === 'ok' && this.inflightOp === null && this.pendingOp !== null)) {
+        return;
+      }
+      this.inflightOp = this.pendingOp;
+      this.inflightCallbacks = this.pendingCallbacks;
+      this.pendingOp = null;
+      this.pendingCallbacks = [];
+      return this.connection.send({
+        doc: this.name,
+        op: this.inflightOp,
+        v: this.version
+      });
+    };
+
+    Doc.prototype.submitOp = function(op, callback) {
+      if (this.type.normalize != null) {
+        op = this.type.normalize(op);
+      }
+      this.snapshot = this.type.apply(this.snapshot, op);
+      if (this.pendingOp !== null) {
+        this.pendingOp = this.type.compose(this.pendingOp, op);
+      } else {
+        this.pendingOp = op;
+      }
+      if (callback) {
+        this.pendingCallbacks.push(callback);
+      }
+      this.emit('change', op);
+      return setTimeout(this.flush, 0);
+    };
+
+    Doc.prototype.shout = function(msg) {
+      return this.connection.send({
+        doc: this.name,
+        meta: {
+          path: ['shout'],
+          value: msg
+        }
+      });
+    };
+
+    Doc.prototype.open = function(callback) {
+      var message,
+        _this = this;
+      this.autoOpen = true;
+      if (this.state !== 'closed') {
+        return;
+      }
+      message = {
+        doc: this.name,
+        open: true
+      };
+      if (this.snapshot === void 0) {
+        message.snapshot = null;
+      }
+      if (this.type) {
+        message.type = this.type.name;
+      }
+      if (this.version != null) {
+        message.v = this.version;
+      }
+      if (this._create) {
+        message.create = true;
+      }
+      this.connection.send(message);
+      this.state = 'opening';
+      return this._openCallback = function(error) {
+        _this._openCallback = null;
+        return typeof callback === "function" ? callback(error) : void 0;
+      };
+    };
+
+    Doc.prototype.close = function(callback) {
+      this.autoOpen = false;
+      if (this.state === 'closed') {
+        return typeof callback === "function" ? callback() : void 0;
+      }
+      this.connection.send({
+        doc: this.name,
+        open: false
+      });
+      this.state = 'closed';
+      this.emit('closing');
+      return this._closeCallback = callback;
+    };
+
+    return Doc;
+
+  })();
+
+  if (typeof WEB === "undefined" || WEB === null) {
+    MicroEvent = require('./microevent');
+  }
+
+  MicroEvent.mixin(Doc);
+
+  exports.Doc = Doc;
+
+}).call(this);

+ 73 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/index.coffee

@@ -0,0 +1,73 @@
+# This file implements the sharejs client, as defined here:
+# https://github.com/josephg/ShareJS/wiki/Client-API
+#
+# It works from both a node.js context and a web context (though in the latter case,
+# it needs to be compiled to work.)
+#
+# It should become a little nicer once I start using more of the new RPC features added
+# in socket.io 0.7.
+#
+# Note that anything declared in the global scope here is shared with other files
+# built by closure. Be careful what you put in this namespace.
+
+unless WEB?
+  Connection = require('./connection').Connection
+
+# Open a document with the given name. The connection is created implicitly and reused.
+#
+# This function uses a local (private) set of connections to support .open().
+#
+# Open returns the connection its using to access the document.
+exports.open = do ->
+  # This is a private connection pool for implicitly created connections.
+  connections = {}
+
+  getConnection = (origin) ->
+    if WEB?
+      location = window.location
+      origin ?= "#{location.protocol}//#{location.host}/channel"
+    
+    unless connections[origin]
+      c = new Connection origin
+
+      del = -> delete connections[origin]
+      c.on 'disconnecting', del
+      c.on 'connect failed', del
+      connections[origin] = c
+    
+    connections[origin]
+
+  # If you're using the bare API, connections are cleaned up as soon as there's no
+  # documents using them.
+  maybeClose = (c) ->
+    numDocs = 0
+    for name, doc of c.docs
+      numDocs++ if doc.state isnt 'closed' || doc.autoOpen
+
+    if numDocs == 0
+      c.disconnect()
+ 
+  (docName, type, origin, callback) ->
+    if typeof origin == 'function'
+      callback = origin
+      origin = null
+
+    c = getConnection origin
+    c.numDocs++
+    c.open docName, type, (error, doc) ->
+      if error
+        callback error
+        maybeClose c
+      else
+        doc.on 'closed', -> maybeClose c
+       
+        callback null, doc
+    
+    c.on 'connect failed'
+    return c
+
+
+unless WEB?
+  exports.Doc = require('./doc').Doc
+  exports.Connection = require('./connection').Connection
+

+ 46 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/microevent.coffee

@@ -0,0 +1,46 @@
+# This is a simple port of microevent.js to Coffeescript. I've changed the
+# function names to be consistent with node.js EventEmitter.
+#
+# microevent.js is copyright Jerome Etienne, and licensed under the MIT license:
+# https://github.com/jeromeetienne/microevent.js
+
+nextTick = if WEB? then (fn) -> setTimeout fn, 0 else process['nextTick']
+
+class MicroEvent
+  on: (event, fct) ->
+    @_events ||= {}
+    @_events[event] ||= []
+    @_events[event].push(fct)
+    this
+
+  removeListener: (event, fct) ->
+    @_events ||= {}
+    listeners = (@_events[event] ||= [])
+    
+    # Sadly, there's no IE8- support for indexOf.
+    i = 0
+    while i < listeners.length
+      listeners[i] = undefined if listeners[i] == fct
+      i++
+
+    nextTick => @_events[event] = (x for x in @_events[event] when x)
+
+    this
+
+  emit: (event, args...) ->
+    return this unless @_events?[event]
+    fn.apply this, args for fn in @_events[event] when fn
+    this
+
+# mixin will delegate all MicroEvent.js function in the destination object
+MicroEvent.mixin = (obj) ->
+  proto = obj.prototype || obj
+
+  # Damn closure compiler :/
+  proto.on = MicroEvent.prototype.on
+  proto.removeListener = MicroEvent.prototype.removeListener
+  proto.emit = MicroEvent.prototype.emit
+  obj
+
+module.exports = MicroEvent unless WEB?
+

+ 85 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/microevent.js

@@ -0,0 +1,85 @@
+// Generated by CoffeeScript 1.4.0
+(function() {
+  var MicroEvent, nextTick,
+    __slice = [].slice;
+
+  nextTick = typeof WEB !== "undefined" && WEB !== null ? function(fn) {
+    return setTimeout(fn, 0);
+  } : process['nextTick'];
+
+  MicroEvent = (function() {
+
+    function MicroEvent() {}
+
+    MicroEvent.prototype.on = function(event, fct) {
+      var _base;
+      this._events || (this._events = {});
+      (_base = this._events)[event] || (_base[event] = []);
+      this._events[event].push(fct);
+      return this;
+    };
+
+    MicroEvent.prototype.removeListener = function(event, fct) {
+      var i, listeners, _base,
+        _this = this;
+      this._events || (this._events = {});
+      listeners = ((_base = this._events)[event] || (_base[event] = []));
+      i = 0;
+      while (i < listeners.length) {
+        if (listeners[i] === fct) {
+          listeners[i] = void 0;
+        }
+        i++;
+      }
+      nextTick(function() {
+        var x;
+        return _this._events[event] = (function() {
+          var _i, _len, _ref, _results;
+          _ref = this._events[event];
+          _results = [];
+          for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+            x = _ref[_i];
+            if (x) {
+              _results.push(x);
+            }
+          }
+          return _results;
+        }).call(_this);
+      });
+      return this;
+    };
+
+    MicroEvent.prototype.emit = function() {
+      var args, event, fn, _i, _len, _ref, _ref1;
+      event = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : [];
+      if (!((_ref = this._events) != null ? _ref[event] : void 0)) {
+        return this;
+      }
+      _ref1 = this._events[event];
+      for (_i = 0, _len = _ref1.length; _i < _len; _i++) {
+        fn = _ref1[_i];
+        if (fn) {
+          fn.apply(this, args);
+        }
+      }
+      return this;
+    };
+
+    return MicroEvent;
+
+  })();
+
+  MicroEvent.mixin = function(obj) {
+    var proto;
+    proto = obj.prototype || obj;
+    proto.on = MicroEvent.prototype.on;
+    proto.removeListener = MicroEvent.prototype.removeListener;
+    proto.emit = MicroEvent.prototype.emit;
+    return obj;
+  };
+
+  if (typeof WEB === "undefined" || WEB === null) {
+    module.exports = MicroEvent;
+  }
+
+}).call(this);

+ 69 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/textarea.coffee

@@ -0,0 +1,69 @@
+# Create an op which converts oldval -> newval.
+#
+# This function should be called every time the text element is changed. Because changes are
+# always localised, the diffing is quite easy.
+#
+# This algorithm is O(N), but I suspect you could speed it up somehow using regular expressions.
+applyChange = (doc, oldval, newval) ->
+  return if oldval == newval
+  commonStart = 0
+  commonStart++ while oldval.charAt(commonStart) == newval.charAt(commonStart)
+
+  commonEnd = 0
+  commonEnd++ while oldval.charAt(oldval.length - 1 - commonEnd) == newval.charAt(newval.length - 1 - commonEnd) and
+    commonEnd + commonStart < oldval.length and commonEnd + commonStart < newval.length
+
+  doc.del commonStart, oldval.length - commonStart - commonEnd unless oldval.length == commonStart + commonEnd
+  doc.insert commonStart, newval[commonStart ... newval.length - commonEnd] unless newval.length == commonStart + commonEnd
+
+window.sharejs.extendDoc 'attach_textarea', (elem) ->
+  doc = this
+  elem.value = @getText()
+  prevvalue = elem.value
+
+  replaceText = (newText, transformCursor) ->
+    newSelection = [
+      transformCursor elem.selectionStart
+      transformCursor elem.selectionEnd
+    ]
+
+    scrollTop = elem.scrollTop
+    elem.value = newText
+    elem.scrollTop = scrollTop if elem.scrollTop != scrollTop
+    [elem.selectionStart, elem.selectionEnd] = newSelection
+
+  @on 'insert', (pos, text) ->
+    transformCursor = (cursor) ->
+      if pos < cursor
+        cursor + text.length
+      else
+        cursor
+    #for IE8 and Opera that replace \n with \r\n.
+    prevvalue = elem.value.replace /\r\n/g, '\n'
+    replaceText prevvalue[...pos] + text + prevvalue[pos..], transformCursor
+  
+  @on 'delete', (pos, text) ->
+    transformCursor = (cursor) ->
+      if pos < cursor
+        cursor - Math.min(text.length, cursor - pos)
+      else
+        cursor
+    #for IE8 and Opera that replace \n with \r\n.
+    prevvalue = elem.value.replace /\r\n/g, '\n'
+    replaceText prevvalue[...pos] + prevvalue[pos + text.length..], transformCursor
+
+  genOp = (event) ->
+    onNextTick = (fn) -> setTimeout fn, 0
+    onNextTick ->
+      if elem.value != prevvalue
+        # IE constantly replaces unix newlines with \r\n. ShareJS docs
+        # should only have unix newlines.
+        prevvalue = elem.value
+        applyChange doc, doc.getText(), elem.value.replace /\r\n/g, '\n'
+
+  for event in ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste']
+    if elem.addEventListener
+      elem.addEventListener event, genOp, false
+    else
+      elem.attachEvent 'on'+event, genOp
+

+ 13 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/client/web-prelude.coffee

@@ -0,0 +1,13 @@
+# This file is included at the top of the compiled client-side javascript
+
+# This way all the modules can add stuff to exports, and for the web client they'll all get exported.
+window.sharejs = exports =
+  'version': '0.5.0'
+
+# This is compiled out when compiled with uglifyjs, but its important for the share.uncompressed.js.
+#
+# Maybe I should rename WEB to __SHAREJS_WEB or something, but its only relevant for testing
+# anyway.
+if typeof WEB == 'undefined'
+  # This will put WEB in the global scope in a browser.
+  window.WEB = true

+ 5 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/index.coffee

@@ -0,0 +1,5 @@
+exports.server = require './server'
+exports.client = require './client'
+exports.types = require './types'
+
+exports.version = '0.5.0'

+ 333 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/browserchannel.coffee

@@ -0,0 +1,333 @@
+# This implements the network API for ShareJS.
+#
+# The wire protocol is speccced out here:
+# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
+#
+# When a client connects the server first authenticates it and sends:
+#
+# S: {auth:<agent session id>}
+#  or
+# S: {auth:null, error:'forbidden'}
+#
+# After that, the client can open documents:
+#
+# C: {doc:'foo', open:true, snapshot:null, create:true, type:'text'}
+# S: {doc:'foo', open:true, snapshot:{snapshot:'hi there', v:5, meta:{}}, create:false}
+#
+# ...
+#
+# The client can send open requests as soon as the socket has opened - it doesn't need to
+# wait for auth.
+#
+# The wire protocol is documented here:
+# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
+
+browserChannel = require('browserchannel').server
+util = require 'util'
+hat = require 'hat'
+
+syncQueue = require './syncqueue'
+
+# Attach the streaming protocol to the supplied http.Server.
+#
+# Options = {}
+module.exports = (createAgent, options) ->
+  options or= {}
+
+  browserChannel options, (session) ->
+    #console.log "New BC session from #{session.address} with id #{session.id}"
+    data =
+      headers: session.headers
+      remoteAddress: session.address
+
+    # This is the user agent through which a connecting client acts. It is set when the
+    # session is authenticated. The agent is responsible for making sure client requests are
+    # properly authorized, and metadata is kept up to date.
+    agent = null
+
+    # To save on network traffic, the agent & server can leave out the docName with each message to mean
+    # 'same as the last message'
+    lastSentDoc = null
+    lastReceivedDoc = null
+
+    # Map from docName -> {queue, listener if open}
+    docState = {}
+
+    # We'll only handle one message from each client at a time.
+    handleMessage = (query) ->
+      #console.log "Message from #{session.id}", query
+
+      error = null
+      error = 'Invalid docName' unless query.doc is null or typeof query.doc is 'string' or (query.doc is undefined and lastReceivedDoc)
+      error = "'create' must be true or missing" unless query.create in [true, undefined]
+      error = "'open' must be true, false or missing" unless query.open in [true, false, undefined]
+      error = "'snapshot' must be null or missing" unless query.snapshot in [null, undefined]
+      error = "'type' invalid" unless query.type is undefined or typeof query.type is 'string'
+      error = "'v' invalid" unless query.v is undefined or (typeof query.v is 'number' and query.v >= 0)
+      
+      if error
+        console.warn "Invalid query #{JSON.stringify query} from #{agent.sessionId}: #{error}"
+        session.abort()
+        return callback()
+
+      # The agent can specify null as the docName to get a random doc name.
+      if query.doc is null
+        query.doc = lastReceivedDoc = hat()
+      else if query.doc != undefined
+        lastReceivedDoc = query.doc
+      else
+        unless lastReceivedDoc
+          console.warn "msg.doc missing in query #{JSON.stringify query} from #{agent.sessionId}"
+          # The disconnect handler will be called when we do this, which will clean up the open docs.
+          return session.abort()
+
+        query.doc = lastReceivedDoc
+
+      docState[query.doc] or= queue: syncQueue (query, callback) ->
+        # When the session is closed, we'll nuke docState. When that happens, no more messages
+        # should be handled.
+        return callback() unless docState
+
+        # Close messages are {open:false}
+        if query.open == false
+          handleClose query, callback
+   
+        # Open messages are {open:true}. There's a lot of shared logic with getting snapshots
+        # and creating documents. These operations can be done together; and I'll handle them
+        # together.
+        else if query.open or query.snapshot is null or query.create
+          # You can open, request a snapshot and create all in the same
+          # request. They're all handled together.
+          handleOpenCreateSnapshot query, callback
+
+        # The socket is submitting an op.
+        else if query.op? or query.meta?.path?
+          handleOp query, callback
+
+        else
+          console.warn "Invalid query #{JSON.stringify query} from #{agent.sessionId}"
+          session.abort()
+          callback()
+
+      # ... And add the message to the queue.
+      docState[query.doc].queue query
+
+
+    # # Some utility methods for message handlers
+
+    # Send a message to the socket.
+    # msg _must_ have the doc:DOCNAME property set. We'll remove it if its the same as lastReceivedDoc.
+    send = (response) ->
+      if response.doc is lastSentDoc
+        delete response.doc
+      else
+        lastSentDoc = response.doc
+
+      # Its invalid to send a message to a closed session. We'll silently drop messages if the
+      # session has closed.
+      if session.state isnt 'closed'
+        #console.log "Sending", response
+        session.send response
+
+    # Open the given document name, at the requested version.
+    # callback(error, version)
+    open = (docName, version, callback) ->
+      return callback 'Session closed' unless docState
+      return callback 'Document already open' if docState[docName].listener
+      #p "Registering listener on #{docName} by #{socket.id} at #{version}"
+
+      docState[docName].listener = listener = (opData) ->
+        throw new Error 'Consistency violation - doc listener invalid' unless docState[docName].listener == listener
+
+        #p "listener doc:#{docName} opdata:#{i opData} v:#{version}"
+
+        # Skip the op if this socket sent it.
+        return if opData.meta.source is agent.sessionId
+
+        opMsg =
+          doc: docName
+          op: opData.op
+          v: opData.v
+          meta: opData.meta
+
+        send opMsg
+      
+      # Tell the socket the doc is open at the requested version
+      agent.listen docName, version, listener, (error, v) ->
+        delete docState[docName].listener if error
+        callback error, v
+
+    # Close the named document.
+    # callback([error])
+    close = (docName, callback) ->
+      #p "Closing #{docName}"
+      return callback 'Session closed' unless docState
+      listener = docState[docName].listener
+      return callback 'Doc already closed' unless listener?
+
+      agent.removeListener docName
+      delete docState[docName].listener
+      callback()
+
+    # Handles messages with any combination of the open:true, create:true and snapshot:null parameters
+    handleOpenCreateSnapshot = (query, finished) ->
+      docName = query.doc
+      msg = doc:docName
+
+      callback = (error) ->
+        if error
+          close(docName) if msg.open == true
+          msg.open = false if query.open == true
+          msg.snapshot = null if query.snapshot != undefined
+          delete msg.create
+
+          msg.error = error
+
+        send msg
+        finished()
+
+      return callback 'No docName specified' unless query.doc?
+
+      if query.create == true
+        if typeof query.type != 'string'
+          return callback 'create:true requires type specified'
+
+      if query.meta != undefined
+        unless typeof query.meta == 'object' and Array.isArray(query.meta) == false
+          return callback 'meta must be an object'
+
+      docData = undefined
+
+      # This is implemented with a series of cascading methods for each different type of
+      # thing this method can handle. This would be so much nicer with an async library. Welcome to
+      # callback hell.
+
+      step1Create = ->
+        return step2Snapshot() if query.create != true
+
+        # The document obviously already exists if we have a snapshot.
+        if docData
+          msg.create = false
+          step2Snapshot()
+        else
+          agent.create docName, query.type, query.meta || {}, (error) ->
+            if error is 'Document already exists'
+              # We've called getSnapshot (-> null), then create (-> already exists). Its possible
+              # another agent has called create() between our getSnapshot and create() calls.
+              agent.getSnapshot docName, (error, data) ->
+                return callback error if error
+
+                docData = data
+                msg.create = false
+                step2Snapshot()
+            else if error
+              callback error
+            else
+              msg.create = true
+              step2Snapshot()
+
+      # The socket requested a document snapshot
+      step2Snapshot = ->
+#        if query.create or query.open or query.snapshot == null
+#          msg.meta = docData.meta
+
+        # Skip inserting a snapshot if the document was just created.
+        if query.snapshot != null or msg.create == true
+          step3Open()
+          return
+
+        if docData
+          msg.v = docData.v
+          msg.type = docData.type.name unless query.type == docData.type.name
+          msg.snapshot = docData.snapshot
+        else
+          return callback 'Document does not exist'
+
+        step3Open()
+
+      # Attempt to open a document with a given name. Version is optional.
+      # callback(opened at version) or callback(null, errormessage)
+      step3Open = ->
+        return callback() if query.open != true
+
+        # Verify the type matches
+        return callback 'Type mismatch' if query.type and docData and query.type != docData.type.name
+
+        open docName, query.v, (error, version) ->
+          return callback error if error
+
+          # + Should fail if the type is wrong.
+
+          #p "Opened #{docName} at #{version} by #{socket.id}"
+          msg.open = true
+          msg.v = version
+          callback()
+
+      # Technically, we don't need a snapshot if the user called create but not open or createSnapshot,
+      # but no clients do that yet anyway.
+      if query.snapshot == null or query.open == true #and query.type
+        agent.getSnapshot query.doc, (error, data) ->
+          return callback error if error and error != 'Document does not exist'
+
+          docData = data
+          step1Create()
+      else
+        step1Create()
+
+    # The socket closes a document
+    handleClose = (query, callback) ->
+      close query.doc, (error) ->
+        if error
+          # An error closing still results in the doc being closed.
+          send {doc:query.doc, open:false, error:error}
+        else
+          send {doc:query.doc, open:false}
+
+        callback()
+
+    # We received an op from the socket
+    handleOp = (query, callback) ->
+      # ...
+      #throw new Error 'No version specified' unless query.v?
+
+      opData = {v:query.v, op:query.op, meta:query.meta, dupIfSource:query.dupIfSource}
+
+      # If it's a metaOp don't send a response
+      agent.submitOp query.doc, opData, if (not opData.op? and opData.meta?.path?) then callback else (error, appliedVersion) ->
+        msg = if error
+          #p "Sending error to socket: #{error}"
+          {doc:query.doc, v:null, error:error}
+        else
+          {doc:query.doc, v:appliedVersion}
+
+        send msg
+        callback()
+
+    # We don't process any messages from the agent until they've authorized. Instead,
+    # they are stored in this buffer.
+    buffer = []
+    session.on 'message', bufferMsg = (msg) -> buffer.push msg
+
+    createAgent data, (error, agent_) ->
+      if error
+        # The client is not authorized, so they shouldn't try and reconnect.
+        session.send {auth:null, error}
+        session.stop()
+      else
+        agent = agent_
+        session.send auth:agent.sessionId
+
+        # Ok. Now we can handle all the messages in the buffer. They'll go straight to
+        # handleMessage from now on.
+        session.removeListener 'message', bufferMsg
+        handleMessage msg for msg in buffer
+        buffer = null
+        session.on 'message', handleMessage
+
+    session.on 'close', ->
+      return unless agent
+      #console.log "Client #{agent.sessionId} disconnected"
+      for docName, {listener} of docState
+        agent.removeListener docName if listener
+      docState = null
+

+ 149 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/db/couchdb.coffee

@@ -0,0 +1,149 @@
+# OT storage for CouchDB
+# Author: Max Ogden (@maxogden)
+#
+# The couchdb database contains two kinds of documents:
+#
+# - Document snapshots have a key which is doc:the document name
+# - Document ops have a random key, but docName: defined.
+
+request = require('request').defaults json: true
+
+# Helper method to parse errors out of couchdb. There's way more ways
+# things can go wrong, but I think this catches all the ones I care about.
+#
+# callback(error) or callback()
+parseError = (err, resp, body, callback) ->
+  body = body[0] if Array.isArray body and body.length >= 1
+
+  if err
+    # This indicates an HTTP error
+    callback err
+  else if resp.statusCode is 404
+    callback 'Document does not exist'
+  else if resp.statusCode is 403
+    callback 'forbidden'
+  else if typeof body is 'object'
+    if body.error is 'conflict'
+      callback 'Document already exists'
+    else if body.error
+      callback "#{body.error} reason: #{body.reason}"
+    else
+      callback()
+  else
+    callback()
+
+module.exports = (options) ->
+  options ?= {}
+  db = options.uri or "http://localhost:5984/sharejs"
+
+  uriForDoc = (docName) -> "#{db}/doc:#{encodeURIComponent docName}"
+  uriForOps = (docName, start, end, include_docs) ->
+    startkey = encodeURIComponent(JSON.stringify [docName, start])
+    # {} is sorted after all numbers - so this will get all ops in the case that end is null.
+    endkey = encodeURIComponent(JSON.stringify [docName, end ? {}])
+
+    # Another way to write this method would be to use node's builtin uri-encoder.
+    extra = if include_docs then '&include_docs=true' else ''
+    "#{db}/_design/sharejs/_view/operations?startkey=#{startkey}&endkey=#{endkey}&inclusive_end=false#{extra}"
+
+  # Helper method to get the revision of a document snapshot.
+  getRev = (docName, dbMeta, callback) ->
+    if dbMeta?.rev
+      callback null, dbMeta.rev
+    else
+      # JSON defaults to true, and that makes request think I'm trying to sneak a request
+      # body in. Ugh.
+      request.head {uri:uriForDoc(docName), json:false}, (err, resp, body) ->
+        parseError err, resp, body, (error) ->
+          if error
+            callback error
+          else
+            # The etag is the rev in quotes.
+            callback null, JSON.parse(resp.headers.etag)
+  
+  writeSnapshotInternal = (docName, data, rev, callback) ->
+    body = data
+    body.fieldType = 'Document'
+    body._rev = rev if rev?
+
+    request.put uri:(uriForDoc docName), body:body, (err, resp, body) ->
+      parseError err, resp, body, (error) ->
+        if error
+          #console.log 'create error'
+          # This will send write conflicts as 'document already exists'. Thats kinda wierd, but
+          # it shouldn't happen anyway
+          callback? error
+        else
+          # We pass the document revision back to the db cache so it can give it back to couchdb on subsequent requests.
+          callback? null, {rev: body.rev}
+
+  # getOps returns all ops between start and end. end can be null.
+  getOps: (docName, start, end, callback) ->
+    return callback null, [] if start == end
+    
+    # Its a bit gross having this end parameter here....
+    endkey = if end? then [docName, end - 1]
+    
+    request uriForOps(docName, start, end), (err, resp, body) ->
+      # Rows look like this:
+      # {"id":"<uuid>","key":["doc name",0],"value":{"op":[{"p":0,"i":"hi"}],"meta":{}}}
+      data = ({op: row.value.op, meta: row.value.meta} for row in body.rows)
+      callback null, data
+  
+  # callback(error, db metadata)
+  create: (docName, data, callback) ->
+    writeSnapshotInternal docName, data, null, callback
+ 
+  delete: del = (docName, dbMeta, callback) ->
+    getRev docName, dbMeta, (error, rev) ->
+      return callback? error if error
+
+      docs = [{_id:"doc:#{docName}", _rev:rev, _deleted:true}]
+      # Its annoying, but we need to get the revision from the document. I don't think there's a simple way to do this.
+      # This request will get all the ops twice.
+      request uriForOps(docName, 0, null, true), (err, resp, body) ->
+        # Rows look like this:
+        # {"id":"<uuid>","key":["doc name",0],"value":{"op":[{"p":0,"i":"hi"}],"meta":{}},
+        #  "doc":{"_id":"<uuid>","_rev":"1-21a40c56ebd5d424ffe56950e77bc847","op":[{"p":0,"i":"hi"}],"v":0,"meta":{},"docName":"doc6"}}
+        for row in body.rows
+          row.doc._deleted = true
+          docs.push row.doc
+
+        request.post url: "#{db}/_bulk_docs", body: {docs}, (err, resp, body) ->
+          if body[0].error is 'conflict'
+            # Somebody has edited the document since we did a GET on the revision information. Recurse.
+            # By passing null to dbMeta I'm forcing the revision information to be reacquired.
+            del docName, null, callback
+          else
+            parseError err, resp, body, (error) -> callback? error
+ 
+  writeOp: (docName, opData, callback) ->
+    body =
+      docName: docName
+      op: opData.op
+      v: opData.v
+      meta: opData.meta
+
+    request.post url:db, body:body, (err, resp, body) ->
+      parseError err, resp, body, callback
+
+  writeSnapshot: (docName, docData, dbMeta, callback) ->
+    getRev docName, dbMeta, (error, rev) ->
+      return callback? error if error
+
+      writeSnapshotInternal docName, docData, rev, callback
+
+  getSnapshot: (docName, callback) ->
+    request uriForDoc(docName), (err, resp, body) ->
+      parseError err, resp, body, (error) ->
+        if error
+          callback error
+        else
+          callback null,
+              snapshot: body.snapshot
+              type: body.type
+              meta: body.meta
+              v: body.v
+            , {rev: body._rev} # dbMeta
+
+  close: ->

+ 28 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/db/index.coffee

@@ -0,0 +1,28 @@
+# This is a simple switch for the different database implementations.
+#
+# The interface is the same as the regular database implementations, except
+# the options object can have another type:<TYPE> parameter which specifies
+# which type of database to use.
+#
+# Example usage:
+#  require('server/db').create {type:'redis'}
+
+defaultType = 'redis'
+
+module.exports = (options) ->
+  options ?= {}
+  type = options.type ? defaultType
+
+  console.warn "Database type: 'memory' detected. This has been deprecated and will
+ be removed in a future version. Use 'none' instead, or just remove the db:{} block
+ from your options." if type is 'memory'
+
+  if type in ['none', 'memory']
+    null
+  else
+    Db = switch type
+      when 'redis' then require './redis'
+      when 'couchdb' then require './couchdb'
+      when 'pg' then require './pg'
+      else throw new Error "Invalid or unsupported database type: '#{type}'"
+    new Db options

+ 198 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/db/pg.coffee

@@ -0,0 +1,198 @@
+# This is an implementation of the OT data backend for PostgreSQL. It requires
+# that you have two tables defined in your schema: one for the snapshots
+# and one for the operations. You must also install the 'pg' package.
+#
+#
+# Example usage:
+#
+#     var connect = require('connect');
+#     var share   = require('share').server;
+#
+#     var server = connect(connect.logger());
+#
+#     var options = {
+#       db: {
+#         type: 'pg',
+#         uri:  'tcp://josh:@localhost/sharejs',
+#         create_tables_automatically: true
+#       }
+#     };
+#
+#     share.attach(server, options);
+#     server.listen(9000);
+#
+# You can run bin/setup_pg to create the SQL tables initially.
+
+pg = require('pg').native
+
+defaultOptions =
+  schema: 'sharejs'
+  create_tables_automatically: true
+  operations_table: 'ops'
+  snapshot_table: 'snapshots'
+
+module.exports = PgDb = (options) ->
+  return new Db if !(this instanceof PgDb)
+
+  options ?= {}
+  options[k] ?= v for k, v of defaultOptions
+
+  client = new pg.Client options.uri
+  client.connect()
+
+  snapshot_table = options.schema and "#{options.schema}.#{options.snapshot_table}" or options.snapshot_table
+  operations_table = options.schema and "#{options.schema}.#{options.operations_table}" or options.operations_table
+
+  @close = ->
+    client.end()
+
+  @initialize = (callback) ->
+    console.warn 'Creating postgresql database tables'
+
+    sql = """
+      CREATE SCHEMA #{options.schema};
+
+      CREATE TABLE #{snapshot_table} (
+        doc text NOT NULL,
+        v int4 NOT NULL,
+        type text NOT NULL,
+        snapshot text NOT NULL,
+        meta text NOT NULL,
+        created_at timestamp(6) NOT NULL,
+        CONSTRAINT snapshots_pkey PRIMARY KEY (doc, v)
+      );
+
+      CREATE TABLE #{operations_table} (
+        doc text NOT NULL,
+        v int4 NOT NULL,
+        op text NOT NULL,
+        meta text NOT NULL,
+        CONSTRAINT operations_pkey PRIMARY KEY (doc, v)
+      );
+    """
+    client.query sql, (error, result) ->
+      callback? error?.message
+
+  # This will perminantly delete all data in the database.
+  @dropTables = (callback) ->
+    sql = "DROP SCHEMA #{options.schema} CASCADE;"
+    client.query sql, (error, result) ->
+      callback? error.message
+
+  @create = (docName, docData, callback) ->
+    sql = """
+      INSERT INTO #{snapshot_table} ("doc", "v", "snapshot", "meta", "type", "created_at")
+        VALUES ($1, $2, $3, $4, $5, now())
+    """
+    values = [docName, docData.v, JSON.stringify(docData.snapshot), JSON.stringify(docData.meta), docData.type]
+    client.query sql, values, (error, result) ->
+      if !error?
+        callback?()
+      else if error.toString().match "duplicate key value violates unique constraint"
+        callback? "Document already exists"
+      else
+        callback? error?.message
+
+  @delete = (docName, dbMeta, callback) ->
+    sql = """
+      DELETE FROM #{operations_table}
+      WHERE "doc" = $1
+      RETURNING *
+    """
+    values = [docName]
+    client.query sql, values, (error, result) ->
+      if !error?
+        sql = """
+          DELETE FROM #{snapshot_table}
+          WHERE "doc" = $1
+          RETURNING *
+        """
+        client.query sql, values, (error, result) ->
+          if !error? and result.rows.length > 0
+            callback?()
+          else if !error?
+            callback? "Document does not exist"
+          else
+            callback? error?.message
+      else
+        callback? error?.message
+
+  @getSnapshot = (docName, callback) ->
+    sql = """
+      SELECT *
+      FROM #{snapshot_table}
+      WHERE "doc" = $1
+      ORDER BY "v" DESC
+      LIMIT 1
+    """
+    values = [docName]
+    client.query sql, values, (error, result) ->
+      if !error? and result.rows.length > 0
+        row = result.rows[0]
+        data =
+          v:        row.v
+          snapshot: JSON.parse(row.snapshot)
+          meta:     JSON.parse(row.meta)
+          type:     row.type
+        callback? null, data
+      else if !error?
+        callback? "Document does not exist"
+      else
+        callback? error?.message
+
+  @writeSnapshot = (docName, docData, dbMeta, callback) ->
+    sql = """
+      UPDATE #{snapshot_table}
+      SET "v" = $2, "snapshot" = $3, "meta" = $4
+      WHERE "doc" = $1
+    """
+    values = [docName, docData.v, JSON.stringify(docData.snapshot), JSON.stringify(docData.meta)]
+    client.query sql, values, (error, result) ->
+      if !error?
+        callback?()
+      else
+        callback? error?.message
+
+  @getOps = (docName, start, end, callback) ->
+    end = if end? then end - 1 else 2147483647
+    sql = """
+      SELECT *
+      FROM #{operations_table}
+      WHERE "v" BETWEEN $1 AND $2
+      AND "doc" = $3
+      ORDER BY "v" ASC
+    """
+    values = [start, end, docName]
+    client.query sql, values, (error, result) ->
+      if !error?
+        data = result.rows.map (row) ->
+          return {
+            op:   JSON.parse row.op
+            # v:    row.version
+            meta: JSON.parse row.meta
+          }
+        callback? null, data
+      else
+        callback? error?.message
+
+  @writeOp = (docName, opData, callback) ->
+    sql = """
+      INSERT INTO #{operations_table} ("doc", "op", "v", "meta")
+        VALUES ($1, $2, $3, $4)
+    """
+    values = [docName, JSON.stringify(opData.op), opData.v, JSON.stringify(opData.meta)]
+    client.query sql, values, (error, result) ->
+      if !error?
+        callback?()
+      else
+        callback? error?.message
+
+  # Immediately try and create the database tables if need be. Its possible that a query
+  # which happens immediately will happen before the database has been initialized.
+  #
+  # But, its not really a big problem.
+  if options.create_tables_automatically
+    client.query "SELECT * from #{snapshot_table} LIMIT 0", (error, result) =>
+      @initialize() if error?.message.match "does not exist"
+
+  this

+ 140 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/db/redis.coffee

@@ -0,0 +1,140 @@
+# This is an implementation of the OT data backend for redis.
+#   http://redis.io/
+#
+# This implementation isn't written to support multiple frontends
+# talking to a single redis backend using redis's transactions.
+
+redis = require 'redis'
+
+defaultOptions = {
+  # Prefix for all database keys.
+  prefix: 'ShareJS:'
+
+  # Inherit the default options from redis. (Hostname: 127.0.0.1, port: 6379)
+  hostname: null
+  port: null
+  redisOptions: null
+  auth: null
+
+  # If this is set to true, the client will select db 15 and wipe all data in
+  # this database.
+  testing: false
+}
+
+# Valid options as above.
+module.exports = RedisDb = (options) ->
+  return new Db if !(this instanceof RedisDb)
+
+  options ?= {}
+  options[k] ?= v for k, v of defaultOptions
+
+  keyForOps = (docName) -> "#{options.prefix}ops:#{docName}"
+  keyForDoc = (docName) -> "#{options.prefix}doc:#{docName}"
+
+  client = redis.createClient options.port, options.hostname, options.redisOptions
+
+  if options.auth and typeof options.auth == "string"
+    client.auth(if ":" in options.auth then options.auth.split(":").pop() else options.auth)
+
+  client.select 15 if options.testing
+
+  # Creates a new document.
+  # data = {snapshot, type:typename, [meta]}
+  # calls callback(true) if the document was created or callback(false) if a document with that name
+  # already exists.
+  @create = (docName, data, callback) ->
+    value = JSON.stringify(data)
+    client.setnx keyForDoc(docName), value, (err, result) ->
+      return callback? err if err
+
+      if result
+        callback?()
+      else
+        callback? 'Document already exists'
+
+  # Get all ops with version = start to version = end. Noninclusive.
+  # end is trimmed to the size of the document.
+  # If any documents are passed to the callback, the first one has v = start
+  # end can be null. If so, returns all documents from start onwards.
+  # Each document returned is in the form {op:o, meta:m, v:version}.
+  @getOps = (docName, start, end, callback) ->
+    if start == end
+      callback null, []
+      return
+
+    # In redis, lrange values are inclusive.
+    if end?
+      end--
+    else
+      end = -1
+
+    client.lrange keyForOps(docName), start, end, (err, values) ->
+      throw err if err?
+      ops = (JSON.parse value for value in values)
+      callback null, ops
+
+  # Write an op to a document.
+  #
+  # opData = {op:the op to append, v:version, meta:optional metadata object containing author, etc.}
+  # callback = callback when op committed
+  # 
+  # opData.v MUST be the subsequent version for the document.
+  #
+  # This function has UNDEFINED BEHAVIOUR if you call append before calling create().
+  # (its either that, or I have _another_ check when you append an op that the document already exists
+  # ... and that would slow it down a bit.)
+  @writeOp = (docName, opData, callback) ->
+    # ****** NOT SAFE FOR MULTIPLE PROCESSES. Rewrite me using transactions or something.
+
+    # The version isn't stored.
+    json = JSON.stringify {op:opData.op, meta:opData.meta}
+    client.rpush keyForOps(docName), json, (err, response) ->
+      return callback err if err
+
+      if response == opData.v + 1
+        callback()
+      else
+        # The document has been corrupted by the change. For now, throw an exception.
+        # Later, rebuild the snapshot.
+        callback "Version mismatch in db.append. '#{docName}' is corrupted."
+    
+  # Write new snapshot data to the database.
+  #
+  # docData = resultant document snapshot data. {snapshot:s, type:t, meta}
+  #
+  # The callback just takes an optional error.
+  #
+  # This function has UNDEFINED BEHAVIOUR if you call append before calling create().
+  @writeSnapshot = (docName, docData, dbMeta, callback) ->
+    client.set keyForDoc(docName), JSON.stringify(docData), (err, response) ->
+      callback? err
+
+  # Data = {v, snapshot, type}. Snapshot == null and v = 0 if the document doesn't exist.
+  @getSnapshot = (docName, callback) ->
+    client.get keyForDoc(docName), (err, response) ->
+      throw err if err?
+
+      if response != null
+        docData = JSON.parse(response)
+        callback null, docData
+      else
+        callback 'Document does not exist'
+
+  # Perminantly deletes a document. There is no undo.
+  # Callback takes a single argument which is true iff something was deleted.
+  @delete = (docName, dbMeta, callback) ->
+    client.del keyForOps(docName)
+    client.del keyForDoc(docName), (err, response) ->
+      throw err if err?
+      if callback
+        if response == 1
+          # Something was deleted.
+          callback null
+        else
+          callback 'Document does not exist'
+  
+  # Close the connection to the database
+  @close = ->
+    client.quit()
+
+  this

+ 57 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/index.coffee

@@ -0,0 +1,57 @@
+# The server module...
+
+connect = require 'connect'
+
+Model = require './model'
+createDb = require './db'
+
+rest = require './rest'
+socketio = require './socketio'
+browserChannel = require './browserchannel'
+
+# Create an HTTP server and attach whatever frontends are specified in the options.
+#
+# The model will be created based on options if it is not specified.
+module.exports = create = (options, model = createModel(options)) ->
+  attach(connect(), options, model)
+
+# Create an OT document model attached to a database.
+create.createModel = createModel = (options) ->
+  dbOptions = options?.db
+
+  db = createDb dbOptions
+  new Model db, options
+
+# Attach the OT server frontends to the provided Node HTTP server. Use this if you
+# already have a http.Server or https.Server and want to make some URL paths do OT.
+#
+# The options object specifies options for everything. If settings are missing,
+# defaults will be provided.
+#
+# Set options.rest == null or options.socketio == null to turn off that frontend.
+create.attach = attach = (server, options, model = createModel(options)) ->
+  options ?= {}
+  options.staticpath ?= '/share'
+
+  server.model = model
+  server.on 'close', -> model.closeDb()
+
+  server.use options.staticpath, connect.static("#{__dirname}/../../webclient") if options.staticpath != null
+
+  createAgent = require('./useragent') model, options
+
+  # The client frontend doesn't get access to the model at all, to make sure security stuff is
+  # done properly.
+  server.use rest(createAgent, options.rest) if options.rest != null
+
+  # Socketio frontend is now disabled by default.
+  socketio.attach(server, createAgent, options.socketio or {}) if options.socketio?
+
+  if options.browserChannel != null
+    options.browserChannel ?= {}
+    #options.browserChannel.base ?= '/sjs'
+    options.browserChannel.server = server
+    server.use browserChannel(createAgent, options.browserChannel)
+
+  server
+

+ 603 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/model.coffee

@@ -0,0 +1,603 @@
+# The model of all the ops. Responsible for applying & transforming remote deltas
+# and managing the storage layer.
+#
+# Actual storage is handled by the database wrappers in db/*, wrapped by DocCache
+
+{EventEmitter} = require 'events'
+
+queue = require './syncqueue'
+types = require '../types'
+
+isArray = (o) -> Object.prototype.toString.call(o) == '[object Array]'
+
+# This constructor creates a new Model object. There will be one model object
+# per server context.
+#
+# The model object is responsible for a lot of things:
+#
+# - It manages the interactions with the database
+# - It maintains (in memory) a set of all active documents
+# - It calls out to the OT functions when necessary
+#
+# The model is an event emitter. It emits the following events:
+#
+# create(docName, data): A document has been created with the specified name & data
+module.exports = Model = (db, options) ->
+  # db can be null if the user doesn't want persistance.
+
+  return new Model(db, options) if !(this instanceof Model)
+
+  model = this
+
+  options ?= {}
+
+  # This is a cache of 'live' documents.
+  #
+  # The cache is a map from docName -> {
+  #   ops:[{op, meta}]
+  #   snapshot
+  #   type
+  #   v
+  #   meta
+  #   eventEmitter
+  #   reapTimer
+  #   committedVersion: v
+  #   snapshotWriteLock: bool to make sure writeSnapshot isn't re-entrant
+  #   dbMeta: database specific data
+  #   opQueue: syncQueue for processing ops
+  # }
+  #
+  # The ops list contains the document's last options.numCachedOps ops. (Or all
+  # of them if we're using a memory store).
+  #
+  # Documents are stored in this set so long as the document has been accessed in
+  # the last few seconds (options.reapTime) OR at least one client has the document
+  # open. I don't know if I should keep open (but not being edited) documents live -
+  # maybe if a client has a document open but the document isn't being edited, I should
+  # flush it from the cache.
+  #
+  # In any case, the API to model is designed such that if we want to change that later
+  # it should be pretty easy to do so without any external-to-the-model code changes.
+  docs = {}
+
+  # This is a map from docName -> [callback]. It is used when a document hasn't been
+  # cached and multiple getSnapshot() / getVersion() requests come in. All requests
+  # are added to the callback list and called when db.getSnapshot() returns.
+  #
+  # callback(error, snapshot data)
+  awaitingGetSnapshot = {}
+
+  # The time that documents which no clients have open will stay in the cache.
+  # Should be > 0.
+  options.reapTime ?= 3000
+
+  # The number of operations the cache holds before reusing the space
+  options.numCachedOps ?= 10
+
+  # This option forces documents to be reaped, even when there's no database backend.
+  # This is useful when you don't care about persistance and don't want to gradually
+  # fill memory.
+  #
+  # You might want to set reapTime to a day or something.
+  options.forceReaping ?= false
+
+  # Until I come up with a better strategy, we'll save a copy of the document snapshot
+  # to the database every ~20 submitted ops.
+  options.opsBeforeCommit ?= 20
+
+  # It takes some processing time to transform client ops. The server will punt ops back to the
+  # client to transform if they're too old.
+  options.maximumAge ?= 40
+
+  # **** Cache API methods
+
+  # Its important that all ops are applied in order. This helper method creates the op submission queue
+  # for a single document. This contains the logic for transforming & applying ops.
+  makeOpQueue = (docName, doc) -> queue (opData, callback) ->
+    return callback 'Version missing' unless opData.v >= 0
+    return callback 'Op at future version' if opData.v > doc.v
+
+    # Punt the transforming work back to the client if the op is too old.
+    return callback 'Op too old' if opData.v + options.maximumAge < doc.v
+
+    opData.meta ||= {}
+    opData.meta.ts = Date.now()
+
+    # We'll need to transform the op to the current version of the document. This
+    # calls the callback immediately if opVersion == doc.v.
+    getOps docName, opData.v, doc.v, (error, ops) ->
+      return callback error if error
+
+      unless doc.v - opData.v == ops.length
+        # This should never happen. It indicates that we didn't get all the ops we
+        # asked for. Its important that the submitted op is correctly transformed.
+        console.error "Could not get old ops in model for document #{docName}"
+        console.error "Expected ops #{opData.v} to #{doc.v} and got #{ops.length} ops"
+        return callback 'Internal error'
+
+      if ops.length > 0
+        try
+          # If there's enough ops, it might be worth spinning this out into a webworker thread.
+          for oldOp in ops
+            # Dup detection works by sending the id(s) the op has been submitted with previously.
+            # If the id matches, we reject it. The client can also detect the op has been submitted
+            # already if it sees its own previous id in the ops it sees when it does catchup.
+            if oldOp.meta.source and opData.dupIfSource and oldOp.meta.source in opData.dupIfSource
+              return callback 'Op already submitted'
+
+            opData.op = doc.type.transform opData.op, oldOp.op, 'left'
+            opData.v++
+        catch error
+          console.error error.stack
+          return callback error.message
+
+      try
+        snapshot = doc.type.apply doc.snapshot, opData.op
+      catch error
+        console.error error.stack
+        return callback error.message
+
+      # The op data should be at the current version, and the new document data should be at
+      # the next version.
+      #
+      # This should never happen in practice, but its a nice little check to make sure everything
+      # is hunky-dory.
+      unless opData.v == doc.v
+        # This should never happen.
+        console.error "Version mismatch detected in model. File a ticket - this is a bug."
+        console.error "Expecting #{opData.v} == #{doc.v}"
+        return callback 'Internal error'
+
+      #newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
+      writeOp = db?.writeOp or (docName, newOpData, callback) -> callback()
+
+      writeOp docName, opData, (error) ->
+        if error
+          # The user should probably know about this.
+          console.warn "Error writing ops to database: #{error}"
+          return callback error
+
+        options.stats?.writeOp?()
+
+        # This is needed when we emit the 'change' event, below.
+        oldSnapshot = doc.snapshot
+
+        # All the heavy lifting is now done. Finally, we'll update the cache with the new data
+        # and (maybe!) save a new document snapshot to the database.
+
+        doc.v = opData.v + 1
+        doc.snapshot = snapshot
+
+        doc.ops.push opData
+        doc.ops.shift() if db and doc.ops.length > options.numCachedOps
+
+        model.emit 'applyOp', docName, opData, snapshot, oldSnapshot
+        doc.eventEmitter.emit 'op', opData, snapshot, oldSnapshot
+
+        # The callback is called with the version of the document at which the op was applied.
+        # This is the op.v after transformation, and its doc.v - 1.
+        callback null, opData.v
+    
+        # I need a decent strategy here for deciding whether or not to save the snapshot.
+        #
+        # The 'right' strategy looks something like "Store the snapshot whenever the snapshot
+        # is smaller than the accumulated op data". For now, I'll just store it every 20
+        # ops or something. (Configurable with doc.committedVersion)
+        if !doc.snapshotWriteLock and doc.committedVersion + options.opsBeforeCommit <= doc.v
+          tryWriteSnapshot docName, (error) ->
+            console.warn "Error writing snapshot #{error}. This is nonfatal" if error
+
+  # Add the data for the given docName to the cache. The named document shouldn't already
+  # exist in the doc set.
+  #
+  # Returns the new doc.
+  add = (docName, error, data, committedVersion, ops, dbMeta) ->
+    callbacks = awaitingGetSnapshot[docName]
+    delete awaitingGetSnapshot[docName]
+
+    if error
+      callback error for callback in callbacks if callbacks
+    else
+      doc = docs[docName] =
+        snapshot: data.snapshot
+        v: data.v
+        type: data.type
+        meta: data.meta
+
+        # Cache of ops
+        ops: ops or []
+
+        eventEmitter: new EventEmitter
+
+        # Timer before the document will be invalidated from the cache (if the document has no
+        # listeners)
+        reapTimer: null
+
+        # Version of the snapshot thats in the database
+        committedVersion: committedVersion ? data.v
+        snapshotWriteLock: false
+        dbMeta: dbMeta
+
+      doc.opQueue = makeOpQueue docName, doc
+      
+      refreshReapingTimeout docName
+      model.emit 'add', docName, data
+      callback null, doc for callback in callbacks if callbacks
+
+    doc
+  
+  # This is a little helper wrapper around db.getOps. It does two things:
+  #
+  # - If there's no database set, it returns an error to the callback
+  # - It adds version numbers to each op returned from the database
+  # (These can be inferred from context so the DB doesn't store them, but its useful to have them).
+  getOpsInternal = (docName, start, end, callback) ->
+    return callback? 'Document does not exist' unless db
+
+    db.getOps docName, start, end, (error, ops) ->
+      return callback? error if error
+
+      v = start
+      op.v = v++ for op in ops
+
+      callback? null, ops
+
+  # Load the named document into the cache. This function is re-entrant.
+  #
+  # The callback is called with (error, doc)
+  load = (docName, callback) ->
+    if docs[docName]
+      # The document is already loaded. Return immediately.
+      options.stats?.cacheHit? 'getSnapshot'
+      return callback null, docs[docName]
+
+    # We're a memory store. If we don't have it, nobody does.
+    return callback 'Document does not exist' unless db
+
+    callbacks = awaitingGetSnapshot[docName]
+
+    # The document is being loaded already. Add ourselves as a callback.
+    return callbacks.push callback if callbacks
+
+    options.stats?.cacheMiss? 'getSnapshot'
+
+    # The document isn't loaded and isn't being loaded. Load it.
+    awaitingGetSnapshot[docName] = [callback]
+    db.getSnapshot docName, (error, data, dbMeta) ->
+      return add docName, error if error
+
+      type = types[data.type]
+      unless type
+        console.warn "Type '#{data.type}' missing"
+        return callback "Type not found"
+      data.type = type
+
+      committedVersion = data.v
+
+      # The server can close without saving the most recent document snapshot.
+      # In this case, there are extra ops which need to be applied before
+      # returning the snapshot.
+      getOpsInternal docName, data.v, null, (error, ops) ->
+        return callback error if error
+
+        if ops.length > 0
+          console.log "Catchup #{docName} #{data.v} -> #{data.v + ops.length}"
+
+          try
+            for op in ops
+              data.snapshot = type.apply data.snapshot, op.op
+              data.v++
+          catch e
+            # This should never happen - it indicates that whats in the
+            # database is invalid.
+            console.error "Op data invalid for #{docName}: #{e.stack}"
+            return callback 'Op data invalid'
+
+        model.emit 'load', docName, data
+        add docName, error, data, committedVersion, ops, dbMeta
+
+  # This makes sure the cache contains a document. If the doc cache doesn't contain
+  # a document, it is loaded from the database and stored.
+  #
+  # Documents are stored so long as either:
+  # - They have been accessed within the past #{PERIOD}
+  # - At least one client has the document open
+  refreshReapingTimeout = (docName) ->
+    doc = docs[docName]
+    return unless doc
+
+    # I want to let the clients list be updated before this is called.
+    process.nextTick ->
+      # This is an awkward way to find out the number of clients on a document. If this
+      # causes performance issues, add a numClients field to the document.
+      #
+      # The first check is because its possible that between refreshReapingTimeout being called and this
+      # event being fired, someone called delete() on the document and hence the doc is something else now.
+      if doc == docs[docName] and
+          doc.eventEmitter.listeners('op').length == 0 and
+          (db or options.forceReaping) and
+          doc.opQueue.busy is false
+
+        clearTimeout doc.reapTimer
+        doc.reapTimer = reapTimer = setTimeout ->
+            tryWriteSnapshot docName, ->
+              # If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
+              # in the middle of applying an operation, don't reap.
+              delete docs[docName] if docs[docName].reapTimer is reapTimer and doc.opQueue.busy is false
+          , options.reapTime
+
+  tryWriteSnapshot = (docName, callback) ->
+    return callback?() unless db
+
+    doc = docs[docName]
+
+    # The doc is closed
+    return callback?() unless doc
+
+    # The document is already saved.
+    return callback?() if doc.committedVersion is doc.v
+
+    return callback? 'Another snapshot write is in progress' if doc.snapshotWriteLock
+
+    doc.snapshotWriteLock = true
+
+    options.stats?.writeSnapshot?()
+
+    writeSnapshot = db?.writeSnapshot or (docName, docData, dbMeta, callback) -> callback()
+
+    data =
+      v: doc.v
+      meta: doc.meta
+      snapshot: doc.snapshot
+      # The database doesn't know about object types.
+      type: doc.type.name
+
+    # Commit snapshot.
+    writeSnapshot docName, data, doc.dbMeta, (error, dbMeta) ->
+      doc.snapshotWriteLock = false
+
+      # We have to use data.v here because the version in the doc could
+      # have been updated between the call to writeSnapshot() and now.
+      doc.committedVersion = data.v
+      doc.dbMeta = dbMeta
+
+      callback? error
+
+  # *** Model interface methods
+
+  # Create a new document.
+  #
+  # data should be {snapshot, type, [meta]}. The version of a new document is 0.
+  @create = (docName, type, meta, callback) ->
+    [meta, callback] = [{}, meta] if typeof meta is 'function'
+
+    return callback? 'Invalid document name' if docName.match /\//
+    return callback? 'Document already exists' if docs[docName]
+
+    type = types[type] if typeof type == 'string'
+    return callback? 'Type not found' unless type
+
+    data =
+      snapshot:type.create()
+      type:type.name
+      meta:meta or {}
+      v:0
+
+    done = (error, dbMeta) ->
+      # dbMeta can be used to cache extra state needed by the database to access the document, like an ID or something.
+      return callback? error if error
+
+      # From here on we'll store the object version of the type name.
+      data.type = type
+      add docName, null, data, 0, [], dbMeta
+      model.emit 'create', docName, data
+      callback?()
+
+    if db
+      db.create docName, data, done
+    else
+      done()
+
+  # Perminantly deletes the specified document.
+  # If listeners are attached, they are removed.
+  # 
+  # The callback is called with (error) if there was an error. If error is null / undefined, the
+  # document was deleted.
+  #
+  # WARNING: This isn't well supported throughout the code. (Eg, streaming clients aren't told about the
+  # deletion. Subsequent op submissions will fail).
+  @delete = (docName, callback) ->
+    doc = docs[docName]
+
+    if doc
+      clearTimeout doc.reapTimer
+      delete docs[docName]
+
+    done = (error) ->
+      model.emit 'delete', docName unless error
+      callback? error
+
+    if db
+      db.delete docName, doc?.dbMeta, done
+    else
+      done (if !doc then 'Document does not exist')
+
+  # This gets all operations from [start...end]. (That is, its not inclusive.)
+  #
+  # end can be null. This means 'get me all ops from start'.
+  #
+  # Each op returned is in the form {op:o, meta:m, v:version}.
+  #
+  # Callback is called with (error, [ops])
+  #
+  # If the document does not exist, getOps doesn't necessarily return an error. This is because
+  # its awkward to figure out whether or not the document exists for things
+  # like the redis database backend. I guess its a bit gross having this inconsistant
+  # with the other DB calls, but its certainly convenient.
+  #
+  # Use getVersion() to determine if a document actually exists, if thats what you're
+  # after.
+  @getOps = getOps = (docName, start, end, callback) ->
+    # getOps will only use the op cache if its there. It won't fill the op cache in.
+    throw new Error 'start must be 0+' unless start >= 0
+
+    [end, callback] = [null, end] if typeof end is 'function'
+
+    ops = docs[docName]?.ops
+
+    if ops
+      version = docs[docName].v
+
+      # Ops contains an array of ops. The last op in the list is the last op applied
+      end ?= version
+      start = Math.min start, end
+
+      return callback null, [] if start == end
+
+      # Base is the version number of the oldest op we have cached
+      base = version - ops.length
+
+      # If the database is null, we'll trim to the ops we do have and hope thats enough.
+      if start >= base or db is null
+        refreshReapingTimeout docName
+        options.stats?.cacheHit 'getOps'
+
+        return callback null, ops[(start - base)...(end - base)]
+
+    options.stats?.cacheMiss 'getOps'
+
+    getOpsInternal docName, start, end, callback
+
+  # Gets the snapshot data for the specified document.
+  # getSnapshot(docName, callback)
+  # Callback is called with (error, {v: <version>, type: <type>, snapshot: <snapshot>, meta: <meta>})
+  @getSnapshot = (docName, callback) ->
+    load docName, (error, doc) ->
+      callback error, if doc then {v:doc.v, type:doc.type, snapshot:doc.snapshot, meta:doc.meta}
+
+  # Gets the latest version # of the document.
+  # getVersion(docName, callback)
+  # callback is called with (error, version).
+  @getVersion = (docName, callback) ->
+    load docName, (error, doc) -> callback error, doc?.v
+
+  # Apply an op to the specified document.
+  # The callback is passed (error, applied version #)
+  # opData = {op:op, v:v, meta:metadata}
+  # 
+  # Ops are queued before being applied so that the following code applies op C before op B:
+  # model.applyOp 'doc', OPA, -> model.applyOp 'doc', OPB
+  # model.applyOp 'doc', OPC
+  @applyOp = (docName, opData, callback) ->
+    # All the logic for this is in makeOpQueue, above.
+    load docName, (error, doc) ->
+      return callback error if error
+
+      process.nextTick -> doc.opQueue opData, (error, newVersion) ->
+        refreshReapingTimeout docName
+        callback? error, newVersion
+
+  # TODO: store (some) metadata in DB
+  # TODO: op and meta should be combineable in the op that gets sent
+  @applyMetaOp = (docName, metaOpData, callback) ->
+    {path, value} = metaOpData.meta
+   
+    return callback? "path should be an array" unless isArray path
+
+    load docName, (error, doc) ->
+      if error?
+        callback? error
+      else
+        applied = false
+        switch path[0]
+          when 'shout'
+            doc.eventEmitter.emit 'op', metaOpData
+            applied = true
+
+        model.emit 'applyMetaOp', docName, path, value if applied
+        callback? null, doc.v
+
+  # Listen to all ops from the specified version. If version is in the past, all
+  # ops since that version are sent immediately to the listener.
+  #
+  # The callback is called once the listener is attached, but before any ops have been passed
+  # to the listener.
+  # 
+  # This will _not_ edit the document metadata.
+  #
+  # If there are any listeners, we don't purge the document from the cache. But be aware, this behaviour
+  # might change in a future version.
+  #
+  # version is the document version at which the document is opened. It can be left out if you want to open
+  # the document at the most recent version.
+  #
+  # listener is called with (opData) each time an op is applied.
+  #
+  # callback(error, openedVersion)
+  @listen = (docName, version, listener, callback) ->
+    [version, listener, callback] = [null, version, listener] if typeof version is 'function'
+
+    load docName, (error, doc) ->
+      return callback? error if error
+
+      clearTimeout doc.reapTimer
+
+      if version?
+        getOps docName, version, null, (error, data) ->
+          return callback? error if error
+
+          doc.eventEmitter.on 'op', listener
+          callback? null, version
+          for op in data
+            listener op
+
+            # The listener may well remove itself during the catchup phase. If this happens, break early.
+            # This is done in a quite inefficient way. (O(n) where n = #listeners on doc)
+            break unless listener in doc.eventEmitter.listeners 'op'
+
+      else # Version is null / undefined. Just add the listener.
+        doc.eventEmitter.on 'op', listener
+        callback? null, doc.v
+
+  # Remove a listener for a particular document.
+  #
+  # removeListener(docName, listener)
+  #
+  # This is synchronous.
+  @removeListener = (docName, listener) ->
+    # The document should already be loaded.
+    doc = docs[docName]
+    throw new Error 'removeListener called but document not loaded' unless doc
+
+    doc.eventEmitter.removeListener 'op', listener
+    refreshReapingTimeout docName
+
+  # Flush saves all snapshot data to the database. I'm not sure whether or not this is actually needed -
+  # sharejs will happily replay uncommitted ops when documents are re-opened anyway.
+  @flush = (callback) ->
+    return callback?() unless db
+
+    pendingWrites = 0
+
+    for docName, doc of docs
+      if doc.committedVersion < doc.v
+        pendingWrites++
+        # I'm hoping writeSnapshot will always happen in another thread.
+        tryWriteSnapshot docName, ->
+          process.nextTick ->
+            pendingWrites--
+            callback?() if pendingWrites is 0
+
+    # If nothing was queued, terminate immediately.
+    callback?() if pendingWrites is 0
+
+  # Close the database connection. This is needed so nodejs can shut down cleanly.
+  @closeDb = ->
+    db?.close?()
+    db = null
+
+  return
+
+# Model inherits from EventEmitter.
+Model:: = new EventEmitter
+

+ 148 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/rest.coffee

@@ -0,0 +1,148 @@
+# A REST-ful frontend to the OT server.
+#
+# See the docs for details and examples about how the protocol works.
+
+http = require 'http'
+url = require 'url'
+
+connect = require 'connect'
+
+send403 = (res, message = 'Forbidden\n') ->
+  res.writeHead 403, {'Content-Type': 'text/plain'}
+  res.end message
+
+send404 = (res, message = '404: Your document could not be found.\n') ->
+  res.writeHead 404, {'Content-Type': 'text/plain'}
+  res.end message
+
+sendError = (res, message, head = false) ->
+  if message == 'forbidden'
+    if head
+      send403 res, ""
+    else
+      send403 res
+  else if message == 'Document does not exist'
+    if head
+      send404 res, ""
+    else
+      send404 res
+  else
+    console.warn "REST server does not know how to send error: '#{message}'"
+    if head
+      res.writeHead 500, {'Content-Type': 'text/plain'}
+      res.end "Error: #{message}\n"
+    else
+      res.writeHead 500, {}
+      res.end ""
+
+send400 = (res, message) ->
+  res.writeHead 400, {'Content-Type': 'text/plain'}
+  res.end message
+
+send200 = (res, message = "OK\n") ->
+  res.writeHead 200, {'Content-Type': 'text/plain'}
+  res.end message
+
+sendJSON = (res, obj) ->
+  res.writeHead 200, {'Content-Type': 'application/json'}
+  res.end JSON.stringify(obj) + '\n'
+
+# Callback is only called if the object was indeed JSON
+expectJSONObject = (req, res, callback) ->
+  pump req, (data) ->
+    try
+      obj = JSON.parse data
+    catch error
+      send400 res, 'Supplied JSON invalid'
+      return
+
+    callback(obj)
+
+pump = (req, callback) ->
+  data = ''
+  req.on 'data', (chunk) -> data += chunk
+  req.on 'end', () -> callback(data)
+
+# connect.router will be removed in connect 2.0 - this code will have to be rewritten or
+# more libraries pulled in.
+# https://github.com/senchalabs/connect/issues/262
+router = (app, createClient, options) ->
+  auth = (req, res, next) ->
+    data =
+      headers: req.headers
+      remoteAddress: req.connection.remoteAddress
+
+    createClient data, (error, client) ->
+      if client
+        req._client = client
+        next()
+      else
+        sendError res, error
+
+  # GET returns the document snapshot. The version and type are sent as headers.
+  # I'm not sure what to do with document metadata - it is inaccessable for now.
+  app.get '/doc/:name', auth, (req, res) ->
+    req._client.getSnapshot req.params.name, (error, doc) ->
+      if doc  
+        res.setHeader 'X-OT-Type', doc.type.name
+        res.setHeader 'X-OT-Version', doc.v
+        if req.method == "HEAD"
+          send200 res, ""
+        else
+          if typeof doc.snapshot == 'string'
+            send200 res, doc.snapshot
+          else
+            sendJSON res, doc.snapshot
+      else
+        if req.method == "HEAD"
+          sendError res, error, true
+        else
+          sendError res, error
+          
+  # Put is used to create a document. The contents are a JSON object with {type:TYPENAME, meta:{...}}
+  app.put '/doc/:name', auth, (req, res) ->
+    expectJSONObject req, res, (obj) ->
+      type = obj?.type
+      meta = obj?.meta
+
+      unless typeof type == 'string' and (meta == undefined or typeof meta == 'object')
+        send400 res, 'Type invalid'
+      else
+        req._client.create req.params.name, type, meta, (error) ->
+          if error
+            sendError res, error
+          else
+            send200 res
+
+  # POST submits an op to the document.
+  app.post '/doc/:name', auth, (req, res) ->
+    query = url.parse(req.url, true).query
+
+    version = if query?.v?
+      parseInt query?.v
+    else
+      parseInt req.headers['x-ot-version']
+    
+    unless version? and version >= 0
+      send400 res, 'Version required - attach query parameter ?v=X on your URL or set the X-OT-Version header'
+    else
+      expectJSONObject req, res, (obj) ->
+        opData = {v:version, op:obj, meta:{source:req.socket.remoteAddress}}
+        req._client.submitOp req.params.name, opData, (error, newVersion) ->
+          if error?
+            sendError res, error
+          else
+            sendJSON res, {v:newVersion}
+
+  app.delete '/doc/:name', auth, (req, res) ->
+    req._client.delete req.params.name, (error) ->
+      if error
+        sendError res, error
+      else
+        send200 res
+
+# Attach the frontend to the supplied http.Server.
+# 
+# As of sharejs 0.4.0, options is ignored. To control the deleting of documents, specify an auth() function.
+module.exports = (createClient, options) ->
+  connect.router (app) -> router(app, createClient, options)

+ 336 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/socketio.coffee

@@ -0,0 +1,336 @@
+# This implements the socketio-based network API for ShareJS.
+#
+# This is the frontend used by the javascript socket implementation.
+#
+# See documentation for this protocol is in doc/protocol.md
+# Tests are in test/socketio.coffee
+#
+# This code will be removed in a future version of sharejs because socket.io is
+# too buggy.
+
+socketio = require 'socket.io'
+util = require 'util'
+hat = require 'hat'
+
+p = ->#util.debug
+i = ->#util.inspect
+
+# Attach the streaming protocol to the supplied http.Server.
+#
+# Options = {}
+exports.attach = (server, createClient, options) ->
+  io = socketio.listen server
+
+  io.configure ->
+    io.set 'log level', 1
+    for option of options
+      io.set option, options[option]
+
+  authClient = (handshakeData, callback) ->
+    data =
+      headers: handshakeData.headers
+      remoteAddress: handshakeData.address.address
+      secure: handshakeData.secure
+
+    createClient data, (error, client) ->
+      if error
+        # Its important that we don't pass the error message to the client here - leaving it as null
+        # will ensure the client recieves the normal 'not_authorized' message and thus
+        # emits 'connect_failed' instead of 'error'
+        callback null, false
+      else
+        handshakeData.client = client
+        callback null, true
+
+  io.of('/sjs').authorization(authClient).on 'connection', (socket) ->
+    client = socket.handshake.client
+
+    # There seems to be a bug in socket.io where socket.request isn't set sometimes.
+    p "New socket connected from #{socket.request.socket.remoteAddress} with id #{socket.id}" if socket.request?
+
+    lastSentDoc = null
+    lastReceivedDoc = null
+
+    # Map from docName -> {listener:fn, queue:[msg], busy:bool}
+    docState = {}
+    closed = false
+
+    # Send a message to the socket.
+    # msg _must_ have the doc:DOCNAME property set. We'll remove it if its the same as lastReceivedDoc.
+    send = (msg) ->
+      if msg.doc == lastSentDoc
+        delete msg.doc
+      else
+        lastSentDoc = msg.doc
+
+      p "Sending #{i msg}"
+      socket.json.send msg
+
+    # Open the given document name, at the requested version.
+    # callback(error, version)
+    open = (docName, version, callback) ->
+      callback 'Doc already opened' if docState[docName].listener?
+      p "Registering listener on #{docName} by #{socket.id} at #{version}"
+
+      # This passes op events to the client
+      docState[docName].listener = listener = (opData) ->
+        throw new Error 'Consistency violation - doc listener invalid' unless docState[docName].listener == listener
+
+        p "listener doc:#{docName} opdata:#{i opData} v:#{version}"
+
+        # Skip the op if this socket sent it.
+        return if opData.meta?.source == client.id
+
+        opMsg =
+          doc: docName
+          op: opData.op
+          v: opData.v
+          meta: opData.meta
+
+        send opMsg
+      
+      # Tell the socket the doc is open at the requested version
+      client.listen docName, version, listener, (error, v) ->
+        delete docState[docName].listener if error
+        callback error, v
+
+    # Close the named document.
+    # callback([error])
+    close = (docName, callback) ->
+      p "Closing #{docName}"
+      listener = docState[docName].listener
+      return callback 'Doc already closed' unless listener?
+
+      client.removeListener docName
+      docState[docName].listener = null
+      callback()
+
+    # Handles messages with any combination of the open:true, create:true and snapshot:null parameters
+    handleOpenCreateSnapshot = (query, finished) ->
+      docName = query.doc
+      msg = doc:docName
+
+      callback = (error) ->
+        if error
+          close(docName) if msg.open == true
+          msg.open = false if query.open == true
+          msg.snapshot = null if query.snapshot != undefined
+          delete msg.create
+
+          msg.error = error
+
+        send msg
+        finished()
+
+      return callback 'No docName specified' unless query.doc?
+
+      if query.create == true
+        if typeof query.type != 'string'
+          return callback 'create:true requires type specified'
+
+      if query.meta != undefined
+        unless typeof query.meta == 'object' and Array.isArray(query.meta) == false
+          return callback 'meta must be an object'
+
+      docData = undefined
+      # Technically, we don't need a snapshot if the user called create but not open or createSnapshot,
+      # but no clients do that yet anyway.
+      #
+      # It might be nice to add a 'createOrGet()' method to model / db manager. But most
+      # of the time clients are opening an existing document rather than creating a new one anyway.
+      ###
+      model.clientGetSnapshot client, query.doc, (error, data) ->
+        maybeCreate = (callback) ->
+          if query.create and error is 'Document does not exist'
+            model.clientCreate client, docName, query.type, query.meta or {}, callback
+          else
+            callback error, data
+
+        maybeCreate (error, data) ->
+          if query.create
+            msg.create = !!error
+          if error is 'Document already exists'
+            msg.create = false
+          else if error and (!msg.create or error isnt 'Document already exists')
+            # This is the real final callback, to say an error has occurred.
+            return callback error
+          else if query.create or query.snapshot is null
+
+
+          if query.snapshot isnt null
+      ###
+
+      # This is implemented with a series of cascading methods for each different type of
+      # thing this method can handle. This would be so much nicer with an async library. Welcome to
+      # callback hell.
+
+      step1Create = ->
+        return step2Snapshot() if query.create != true
+
+        # The document obviously already exists if we have a snapshot.
+        if docData
+          msg.create = false
+          step2Snapshot()
+        else
+          client.create docName, query.type, query.meta || {}, (error) ->
+            if error is 'Document already exists'
+              # We've called getSnapshot (-> null), then createClient (-> already exists). Its possible
+              # another client has called createClient first.
+              client.getSnapshot docName, (error, data) ->
+                return callback error if error
+
+                docData = data
+                msg.create = false
+                step2Snapshot()
+            else if error
+              callback error
+            else
+              msg.create = !error
+              step2Snapshot()
+
+      # The socket requested a document snapshot
+      step2Snapshot = ->
+        # Skip inserting a snapshot if the document was just created.
+        if query.snapshot != null or msg.create == true
+          step3Open()
+          return
+
+        if docData
+          msg.v = docData.v
+          msg.type = docData.type.name unless query.type == docData.type.name
+          msg.snapshot = docData.snapshot
+        else
+          return callback 'Document does not exist'
+
+        step3Open()
+
+      # Attempt to open a document with a given name. Version is optional.
+      # callback(opened at version) or callback(null, errormessage)
+      step3Open = ->
+        return callback() if query.open != true
+
+        # Verify the type matches
+        return callback 'Type mismatch' if query.type and docData and query.type != docData.type.name
+
+        open docName, query.v, (error, version) ->
+          return callback error if error
+
+          # + Should fail if the type is wrong.
+
+          p "Opened #{docName} at #{version} by #{socket.id}"
+          msg.open = true
+          msg.v = version
+          callback()
+
+      if query.snapshot == null or (query.open == true and query.type)
+        client.getSnapshot query.doc, (error, data) ->
+          return callback error if error and error != 'Document does not exist'
+
+          docData = data
+          step1Create()
+      else
+        step1Create()
+
+    # The socket closes a document
+    handleClose = (query, callback) ->
+      close query.doc, (error) ->
+        if error
+          # An error closing still results in the doc being closed.
+          send {doc:query.doc, open:false, error:error}
+        else
+          send {doc:query.doc, open:false}
+
+        callback()
+
+    # We received an op from the socket
+    handleOp = (query, callback) ->
+      throw new Error 'No docName specified' unless query.doc?
+      throw new Error 'No version specified' unless query.v? or (query.meta?.path? and query.meta?.value?)
+
+      op_data = {v:query.v, op:query.op}
+      op_data.meta = query.meta || {}
+      op_data.meta.source = socket.id
+
+      client.submitOp query.doc, op_data, (error, appliedVersion) ->
+        msg = if error
+          p "Sending error to socket: #{error}"
+          {doc:query.doc, v:null, error:error}
+        else
+          {doc:query.doc, v:appliedVersion}
+
+        p "sending #{i msg}"
+        send msg
+        callback()
+
+    flush = (state) ->
+      return if state.busy || state.queue.length == 0
+      state.busy = true
+
+      query = state.queue.shift()
+
+      callback = ->
+        state.busy = false
+        flush state
+
+      p "processing query #{i query}"
+      try
+        if query.open == false
+          handleClose query, callback
+
+        else if query.open != undefined or query.snapshot != undefined or query.create
+          # You can open, request a snapshot and create all in the same
+          # request. They're all handled together.
+          handleOpenCreateSnapshot query, callback
+
+        else if query.op? or query.meta? # The socket is applying an op.
+          handleOp query, callback
+
+        else
+          util.debug "Unknown message received: #{util.inspect query}"
+
+      catch error
+        util.debug error.stack
+        # ... And disconnect the socket?
+        callback()
+    
+    # And now the actual message handler.
+    messageListener = (query) ->
+      p "Server recieved message #{i query}"
+      # There seems to be a bug in socket.io where messages are detected
+      # after the client disconnects.
+      if closed
+        console.warn "WARNING: received query from socket after the socket disconnected."
+        console.warn socket
+        return
+
+      try
+        query = JSON.parse query if typeof(query) == 'string'
+
+        if query.doc == null
+          lastReceivedDoc = null
+          query.doc = hat()
+        else if query.doc != undefined
+          lastReceivedDoc = query.doc
+        else
+          throw new Error 'msg.doc missing. Probably the client reconnected without telling us - this is a socket.io bug.' unless lastReceivedDoc
+          query.doc = lastReceivedDoc
+      catch error
+        util.debug error.stack
+        return
+
+      docState[query.doc] ||= {listener:null, queue:[], busy:no}
+      docState[query.doc].queue.push query
+      flush docState[query.doc]
+
+    socket.on 'message', messageListener
+    socket.on 'disconnect', ->
+      p "socket #{socket.id} disconnected"
+      closed = true
+      for docName, state of docState
+        state.busy = true
+        state.queue = []
+        client.removeListener docName if state.listener?
+      socket.removeListener 'message', messageListener
+      docState = {}
+  
+  server

+ 42 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/syncqueue.coffee

@@ -0,0 +1,42 @@
+# A synchronous processing queue. The queue calls process on the arguments,
+# ensuring that process() is only executing once at a time.
+#
+# process(data, callback) _MUST_ eventually call its callback.
+#
+# Example:
+#
+# queue = require 'syncqueue'
+#
+# fn = queue (data, callback) ->
+#     asyncthing data, ->
+#         callback(321)
+#
+# fn(1)
+# fn(2)
+# fn(3, (result) -> console.log(result))
+#
+#   ^--- async thing will only be running once at any time.
+
+module.exports = (process) ->
+  throw new Error('process is not a function') unless typeof process == 'function'
+  queue = []
+  
+  enqueue = (data, callback) ->
+    queue.push [data, callback]
+    flush()
+
+  enqueue.busy = false
+
+  flush = ->
+    return if enqueue.busy or queue.length == 0
+
+    enqueue.busy = true
+    [data, callback] = queue.shift()
+    process data, (result...) -> # TODO: Make this not use varargs - varargs are really slow.
+      enqueue.busy = false
+      # This is called after busy = false so a user can check if enqueue.busy is set in the callback.
+      callback.apply null, result if callback
+      flush()
+
+  enqueue
+

+ 146 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/server/useragent.coffee

@@ -0,0 +1,146 @@
+# A useragent is assigned to each client when the client connects. The useragent is responsible for making
+# sure all requests are authorized and maintaining document metadata.
+#
+# This is used by all the client frontends to interact with the server.
+
+hat = require 'hat'
+types = require '../types'
+
+# This module exports a function which you can call with the model and options. Calling the function
+# returns _another_ function which you can call when clients connect.
+module.exports = (model, options) ->
+  # By default, accept all connections + data submissions.
+  # Don't let anyone delete documents though.
+  auth = options.auth or (agent, action) ->
+    if action.type in ['connect', 'read', 'create', 'update'] then action.accept() else action.reject()
+
+  class UserAgent
+    constructor: (data) ->
+      @sessionId = hat()
+      @connectTime = new Date
+      @headers = data.headers
+      @remoteAddress = data.remoteAddress
+
+      # This is a map from docName -> listener function
+      @listeners = {}
+
+      # Should be manually set by the auth function.
+      @name = null
+
+      # We have access to these with socket.io, but I'm not sure we can support
+      # these properties on the REST API or sockjs, etc.
+      #xdomain: data.xdomain
+      #secure: data.secure
+
+    # This is a helper method which wraps auth() above. It creates the action and calls
+    # auth. If authentication succeeds, acceptCallback() is called if it exists. otherwise
+    # userCallback(true) is called.
+    #
+    # If authentication fails, userCallback('forbidden', null) is called.
+    #
+    # If supplied, actionData is turned into the action object passed to auth.
+    doAuth: (actionData, name, userCallback, acceptCallback) ->
+      action = actionData || {}
+      action.name = name
+      action.type = switch name
+        when 'connect' then 'connect'
+        when 'create' then 'create'
+        when 'get snapshot', 'get ops', 'open' then 'read'
+        when 'submit op' then 'update'
+        when 'submit meta' then 'update'
+        when 'delete' then 'delete'
+        else throw new Error "Invalid action name #{name}"
+
+      responded = false
+      action.reject = ->
+        throw new Error 'Multiple accept/reject calls made' if responded
+        responded = true
+        userCallback 'forbidden', null
+      action.accept = ->
+        throw new Error 'Multiple accept/reject calls made' if responded
+        responded = true
+        acceptCallback()
+
+      auth this, action
+
+    disconnect: ->
+      model.removeListener docName, listener for docName, listener of @listeners
+
+    getOps: (docName, start, end, callback) ->
+      @doAuth {docName, start, end}, 'get ops', callback, ->
+        model.getOps docName, start, end, callback
+
+    getSnapshot: (docName, callback) ->
+      @doAuth {docName}, 'get snapshot', callback, ->
+        model.getSnapshot docName, callback
+    
+    create: (docName, type, meta, callback) ->
+      # We don't check that types[type.name] == type. That might be important at some point.
+      type = types[type] if typeof type == 'string'
+
+      # I'm not sure what client-specified metadata should be allowed in the document metadata
+      # object. For now, I'm going to ignore all create metadata until I know how it should work.
+      meta = {}
+
+      meta.creator = @name if @name
+      meta.ctime = meta.mtime = Date.now()
+
+      # The action object has a 'type' property already. Hence the doc type is renamed to 'docType'
+      @doAuth {docName, docType:type, meta}, 'create', callback, =>
+        model.create docName, type, meta, callback
+
+    submitOp: (docName, opData, callback) ->
+      opData.meta ||= {}
+      opData.meta.source = @sessionId
+      dupIfSource = opData.dupIfSource or []
+
+      # If ops and meta get coalesced, they should be separated here.
+      if opData.op
+        @doAuth {docName, op:opData.op, v:opData.v, meta:opData.meta, dupIfSource}, 'submit op', callback, =>
+          model.applyOp docName, opData, callback
+      else
+        @doAuth {docName, meta:opData.meta}, 'submit meta', callback, =>
+          model.applyMetaOp docName, opData, callback
+
+    # Delete the named operation.
+    # Callback is passed (deleted?, error message)
+    delete: (docName, callback) ->
+      @doAuth {docName}, 'delete', callback, =>
+        model.delete docName, callback
+    
+    # Open the named document for reading. Just like model.listen, version is optional.
+    listen: (docName, version, listener, callback) ->
+      authOps = if version?
+        # If the specified version is older than the current version, we have to also check that the
+        # agent is allowed to get ops from the specified version.
+        #
+        # We _could_ check the version number of the document and then only check getOps if
+        # the specified version is old, but an auth check is almost certainly faster than a db roundtrip.
+        (c) => @doAuth {docName, start:version, end:null}, 'get ops', callback, c
+      else
+        (c) -> c()
+
+      authOps =>
+        @doAuth {docName, v:version if version?}, 'open', callback, =>
+          return callback? 'Document is already open' if @listeners[docName]
+          @listeners[docName] = listener
+
+          model.listen docName, version, listener, (error, v) =>
+            if error
+              delete @listeners[docName]
+
+            callback? error, v
+
+    removeListener: (docName) ->
+      throw new Error 'Document is not open' unless @listeners[docName]
+      model.removeListener docName, @listeners[docName]
+      delete @listeners[docName]
+
+  # Finally, return a function which takes client data and returns an authenticated useragent object
+  # through a callback.
+  (data, callback) ->
+    agent = new UserAgent data
+    agent.doAuth null, 'connect', callback, ->
+      # Maybe store a set of agents? Is that useful?
+      callback null, agent
+

+ 48 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/README.md

@@ -0,0 +1,48 @@
+This directory contains all the operational transform code. Each file defines a type.
+
+Most of the types in here are for testing or demonstration. The only types which are sent to the webclient
+are `text` and `json`.
+
+
+# An OT type
+
+All OT types have the following fields:
+
+`name`: _(string)_ Name of the type. Should match the filename.
+`create() -> snapshot`: Function which creates and returns a new document snapshot
+
+`apply(snapshot, op) -> snapshot`: A function which creates a new document snapshot with the op applied
+`transform(op1, op2, side) -> op1'`: OT transform function.
+
+Given op1, op2, `apply(s, op2, transform(op1, op2, 'left')) == apply(s, op1, transform(op2, op1, 'right'))`.
+
+Transform and apply must never modify their arguments.
+
+
+Optional properties:
+
+`tp2`: _(bool)_ True if the transform function supports TP2. This allows p2p architectures to work.
+`compose(op1, op2) -> op`: Create and return a new op which has the same effect as op1 + op2.
+`serialize(snapshot) -> JSON object`: Serialize a document to something we can JSON.stringify()
+`deserialize(object) -> snapshot`: Deserialize a JSON object into the document's internal snapshot format
+`prune(op1', op2, side) -> op1`: Inserse transform function. Only required for TP2 types.
+`normalize(op) -> op`: Fix up an op to make it valid. Eg, remove skips of size zero.
+`api`: _(object)_ Set of helper methods which will be mixed in to the client document object for manipulating documents. See below.
+
+
+# Examples
+
+`count` and `simple` are two trivial OT type definitions if you want to take a look. JSON defines
+the ot-for-JSON type (see the wiki for documentation) and all the text types define different text
+implementations. (I still have no idea which one I like the most, and they're fun to write!)
+
+
+# API
+
+Types can also define API functions. These methods are mixed into the client's Doc object when a document is created.
+You can use them to help construct ops programatically (so users don't need to understand how ops are structured).
+
+For example, the three text types defined here (text, text-composable and text-tp2) all provide the text API, supplying
+`.insert()`, `.del()`, `.getLength` and `.getText` methods.
+
+See text-api.coffee for an example.

+ 22 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/count.coffee

@@ -0,0 +1,22 @@
+# This is a simple type used for testing other OT code. Each op is [expectedSnapshot, increment]
+
+exports.name = 'count'
+exports.create = -> 1
+
+exports.apply = (snapshot, op) ->
+  [v, inc] = op
+  throw new Error "Op #{v} != snapshot #{snapshot}" unless snapshot == v
+  snapshot + inc
+
+# transform op1 by op2. Return transformed version of op1.
+exports.transform = (op1, op2) ->
+  throw new Error "Op1 #{op1[0]} != op2 #{op2[0]}" unless op1[0] == op2[0]
+  [op1[0] + op2[1], op1[1]]
+
+exports.compose = (op1, op2) ->
+  throw new Error "Op1 #{op1} + 1 != op2 #{op2}" unless op1[0] + op1[1] == op2[0]
+  [op1[0], op1[1] + op2[1]]
+
+exports.generateRandomOp = (doc) ->
+  [[doc, 1], doc + 1]
+

+ 65 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/helpers.coffee

@@ -0,0 +1,65 @@
+# These methods let you build a transform function from a transformComponent function
+# for OT types like text and JSON in which operations are lists of components
+# and transforming them requires N^2 work.
+
+# Add transform and transformX functions for an OT type which has transformComponent defined.
+# transformComponent(destination array, component, other component, side)
+exports['_bt'] = bootstrapTransform = (type, transformComponent, checkValidOp, append) ->
+  transformComponentX = (left, right, destLeft, destRight) ->
+    transformComponent destLeft, left, right, 'left'
+    transformComponent destRight, right, left, 'right'
+
+  # Transforms rightOp by leftOp. Returns ['rightOp', clientOp']
+  type.transformX = type['transformX'] = transformX = (leftOp, rightOp) ->
+    checkValidOp leftOp
+    checkValidOp rightOp
+
+    newRightOp = []
+
+    for rightComponent in rightOp
+      # Generate newLeftOp by composing leftOp by rightComponent
+      newLeftOp = []
+
+      k = 0
+      while k < leftOp.length
+        nextC = []
+        transformComponentX leftOp[k], rightComponent, newLeftOp, nextC
+        k++
+
+        if nextC.length == 1
+          rightComponent = nextC[0]
+        else if nextC.length == 0
+          append newLeftOp, l for l in leftOp[k..]
+          rightComponent = null
+          break
+        else
+          # Recurse.
+          [l_, r_] = transformX leftOp[k..], nextC
+          append newLeftOp, l for l in l_
+          append newRightOp, r for r in r_
+          rightComponent = null
+          break
+    
+      append newRightOp, rightComponent if rightComponent?
+      leftOp = newLeftOp
+    
+    [leftOp, newRightOp]
+
+  # Transforms op with specified type ('left' or 'right') by otherOp.
+  type.transform = type['transform'] = (op, otherOp, type) ->
+    throw new Error "type must be 'left' or 'right'" unless type == 'left' or type == 'right'
+
+    return op if otherOp.length == 0
+
+    # TODO: Benchmark with and without this line. I _think_ it'll make a big difference...?
+    return transformComponent [], op[0], otherOp[0], type if op.length == 1 and otherOp.length == 1
+
+    if type == 'left'
+      [left, _] = transformX op, otherOp
+      left
+    else
+      [_, right] = transformX otherOp, op
+      right
+
+if typeof WEB is 'undefined'
+  exports.bootstrapTransform = bootstrapTransform

+ 15 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/index.coffee

@@ -0,0 +1,15 @@
+
+register = (file) ->
+  type = require file
+  exports[type.name] = type
+  try require "#{file}-api"
+
+# Import all the built-in types.
+register './simple'
+register './count'
+
+register './text'
+register './text-composable'
+register './text-tp2'
+
+register './json'

+ 180 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/json-api.coffee

@@ -0,0 +1,180 @@
+# API for JSON OT
+
+json = require './json' if typeof WEB is 'undefined'
+
+if WEB?
+  extendDoc = exports.extendDoc
+  exports.extendDoc = (name, fn) ->
+    SubDoc::[name] = fn
+    extendDoc name, fn
+
+depath = (path) ->
+  if path.length == 1 and path[0].constructor == Array
+    path[0]
+  else path
+
+class SubDoc
+  constructor: (@doc, @path) ->
+  at: (path...) -> @doc.at @path.concat depath path
+  get: -> @doc.getAt @path
+  # for objects and lists
+  set: (value, cb) -> @doc.setAt @path, value, cb
+  # for strings and lists.
+  insert: (pos, value, cb) -> @doc.insertAt @path, pos, value, cb
+  # for strings
+  del: (pos, length, cb) -> @doc.deleteTextAt @path, length, pos, cb
+  # for objects and lists
+  remove: (cb) -> @doc.removeAt @path, cb
+  push: (value, cb) -> @insert @get().length, value, cb
+  move: (from, to, cb) -> @doc.moveAt @path, from, to, cb
+  add: (amount, cb) -> @doc.addAt @path, amount, cb
+  on: (event, cb) -> @doc.addListener @path, event, cb
+  removeListener: (l) -> @doc.removeListener l
+
+  # text API compatibility
+  getLength: -> @get().length
+  getText: -> @get()
+
+traverse = (snapshot, path) ->
+  container = data:snapshot
+  key = 'data'
+  elem = container
+  for p in path
+    elem = elem[key]
+    key = p
+    throw new Error 'bad path' if typeof elem == 'undefined'
+  {elem, key}
+
+pathEquals = (p1, p2) ->
+  return false if p1.length != p2.length
+  for e,i in p1
+    return false if e != p2[i]
+  true
+
+json.api =
+  provides: {json:true}
+
+  at: (path...) -> new SubDoc this, depath path
+
+  get: -> @snapshot
+  set: (value, cb) -> @setAt [], value, cb
+
+  getAt: (path) ->
+    {elem, key} = traverse @snapshot, path
+    return elem[key]
+
+  setAt: (path, value, cb) ->
+    {elem, key} = traverse @snapshot, path
+    op = {p:path}
+    if elem.constructor == Array
+      op.li = value
+      op.ld = elem[key] if typeof elem[key] != 'undefined'
+    else if typeof elem == 'object'
+      op.oi = value
+      op.od = elem[key] if typeof elem[key] != 'undefined'
+    else throw new Error 'bad path'
+    @submitOp [op], cb
+
+  removeAt: (path, cb) ->
+    {elem, key} = traverse @snapshot, path
+    throw new Error 'no element at that path' unless typeof elem[key] != 'undefined'
+    op = {p:path}
+    if elem.constructor == Array
+      op.ld = elem[key]
+    else if typeof elem == 'object'
+      op.od = elem[key]
+    else throw new Error 'bad path'
+    @submitOp [op], cb
+
+  insertAt: (path, pos, value, cb) ->
+    {elem, key} = traverse @snapshot, path
+    op = {p:path.concat pos}
+    if elem[key].constructor == Array
+      op.li = value
+    else if typeof elem[key] == 'string'
+      op.si = value
+    @submitOp [op], cb
+
+  moveAt: (path, from, to, cb) ->
+    op = [{p:path.concat(from), lm:to}]
+    @submitOp op, cb
+
+  addAt: (path, amount, cb) ->
+    op = [{p:path, na:amount}]
+    @submitOp op, cb
+
+  deleteTextAt: (path, length, pos, cb) ->
+    {elem, key} = traverse @snapshot, path
+    op = [{p:path.concat(pos), sd:elem[key][pos...(pos + length)]}]
+    @submitOp op, cb
+
+  addListener: (path, event, cb) ->
+    l = {path, event, cb}
+    @_listeners.push l
+    l
+  removeListener: (l) ->
+    i = @_listeners.indexOf l
+    return false if i < 0
+    @_listeners.splice i, 1
+    return true
+  _register: ->
+    @_listeners = []
+    @on 'change', (op) ->
+      for c in op
+        if c.na != undefined or c.si != undefined or c.sd != undefined
+          # no change to structure
+          continue
+        to_remove = []
+        for l, i in @_listeners
+          # Transform a dummy op by the incoming op to work out what
+          # should happen to the listener.
+          dummy = {p:l.path, na:0}
+          xformed = @type.transformComponent [], dummy, c, 'left'
+          if xformed.length == 0
+            # The op was transformed to noop, so we should delete the listener.
+            to_remove.push i
+          else if xformed.length == 1
+            # The op remained, so grab its new path into the listener.
+            l.path = xformed[0].p
+          else
+            throw new Error "Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components."
+        to_remove.sort (a, b) -> b - a
+        for i in to_remove
+          @_listeners.splice i, 1
+    @on 'remoteop', (op) ->
+      for c in op
+        match_path = if c.na == undefined then c.p[...c.p.length-1] else c.p
+        for {path, event, cb} in @_listeners
+          if pathEquals path, match_path
+            switch event
+              when 'insert'
+                if c.li != undefined and c.ld == undefined
+                  cb(c.p[c.p.length-1], c.li)
+                else if c.oi != undefined and c.od == undefined
+                  cb(c.p[c.p.length-1], c.oi)
+                else if c.si != undefined
+                  cb(c.p[c.p.length-1], c.si)
+              when 'delete'
+                if c.li == undefined and c.ld != undefined
+                  cb(c.p[c.p.length-1], c.ld)
+                else if c.oi == undefined and c.od != undefined
+                  cb(c.p[c.p.length-1], c.od)
+                else if c.sd != undefined
+                  cb(c.p[c.p.length-1], c.sd)
+              when 'replace'
+                if c.li != undefined and c.ld != undefined
+                  cb(c.p[c.p.length-1], c.ld, c.li)
+                else if c.oi != undefined and c.od != undefined
+                  cb(c.p[c.p.length-1], c.od, c.oi)
+              when 'move'
+                if c.lm != undefined
+                  cb(c.p[c.p.length-1], c.lm)
+              when 'add'
+                if c.na != undefined
+                  cb(c.na)
+          else if (common = @type.commonPath match_path, path)?
+            if event == 'child op'
+              if match_path.length == path.length == common
+                throw new Error "paths match length and have commonality, but aren't equal?"
+              child_path = c.p[common+1..]
+              cb(child_path, c)

+ 441 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/json.coffee

@@ -0,0 +1,441 @@
+# This is the implementation of the JSON OT type.
+#
+# Spec is here: https://github.com/josephg/ShareJS/wiki/JSON-Operations
+
+if WEB?
+  text = exports.types.text
+else
+  text = require './text'
+
+json = {}
+
+json.name = 'json'
+
+json.create = -> null
+
+json.invertComponent = (c) ->
+  c_ = {p: c.p}
+  c_.sd = c.si if c.si != undefined
+  c_.si = c.sd if c.sd != undefined
+  c_.od = c.oi if c.oi != undefined
+  c_.oi = c.od if c.od != undefined
+  c_.ld = c.li if c.li != undefined
+  c_.li = c.ld if c.ld != undefined
+  c_.na = -c.na if c.na != undefined
+  if c.lm != undefined
+    c_.lm = c.p[c.p.length-1]
+    c_.p = c.p[0...c.p.length - 1].concat([c.lm])
+  c_
+
+json.invert = (op) -> json.invertComponent c for c in op.slice().reverse()
+
+json.checkValidOp = (op) ->
+
+isArray = (o) -> Object.prototype.toString.call(o) == '[object Array]'
+json.checkList = (elem) ->
+  throw new Error 'Referenced element not a list' unless isArray(elem)
+
+json.checkObj = (elem) ->
+  throw new Error "Referenced element not an object (it was #{JSON.stringify elem})" unless elem.constructor is Object
+
+json.apply = (snapshot, op) ->
+  json.checkValidOp op
+  op = clone op
+
+  container = {data: clone snapshot}
+
+  try
+    for c, i in op
+      parent = null
+      parentkey = null
+      elem = container
+      key = 'data'
+
+      for p in c.p
+        parent = elem
+        parentkey = key
+        elem = elem[key]
+        key = p
+
+        throw new Error 'Path invalid' unless parent?
+
+      if c.na != undefined
+        # Number add
+        throw new Error 'Referenced element not a number' unless typeof elem[key] is 'number'
+        elem[key] += c.na
+
+      else if c.si != undefined
+        # String insert
+        throw new Error "Referenced element not a string (it was #{JSON.stringify elem})" unless typeof elem is 'string'
+        parent[parentkey] = elem[...key] + c.si + elem[key..]
+      else if c.sd != undefined
+        # String delete
+        throw new Error 'Referenced element not a string' unless typeof elem is 'string'
+        throw new Error 'Deleted string does not match' unless elem[key...key + c.sd.length] == c.sd
+        parent[parentkey] = elem[...key] + elem[key + c.sd.length..]
+
+      else if c.li != undefined && c.ld != undefined
+        # List replace
+        json.checkList elem
+
+        # Should check the list element matches c.ld
+        elem[key] = c.li
+      else if c.li != undefined
+        # List insert
+        json.checkList elem
+
+        elem.splice key, 0, c.li
+      else if c.ld != undefined
+        # List delete
+        json.checkList elem
+
+        # Should check the list element matches c.ld here too.
+        elem.splice key, 1
+      else if c.lm != undefined
+        # List move
+        json.checkList elem
+        if c.lm != key
+          e = elem[key]
+          # Remove it...
+          elem.splice key, 1
+          # And insert it back.
+          elem.splice c.lm, 0, e
+
+      else if c.oi != undefined
+        # Object insert / replace
+        json.checkObj elem
+        
+        # Should check that elem[key] == c.od
+        elem[key] = c.oi
+      else if c.od != undefined
+        # Object delete
+        json.checkObj elem
+
+        # Should check that elem[key] == c.od
+        delete elem[key]
+      else
+        throw new Error 'invalid / missing instruction in op'
+  catch error
+    # TODO: Roll back all already applied changes. Write tests before implementing this code.
+    throw error
+
+  container.data
+
+# Checks if two paths, p1 and p2 match.
+json.pathMatches = (p1, p2, ignoreLast) ->
+  return false unless p1.length == p2.length
+
+  for p, i in p1
+    return false if p != p2[i] and (!ignoreLast or i != p1.length - 1)
+      
+  true
+
+json.append = (dest, c) ->
+  c = clone c
+  if dest.length != 0 and json.pathMatches c.p, (last = dest[dest.length - 1]).p
+    if last.na != undefined and c.na != undefined
+      dest[dest.length - 1] = { p: last.p, na: last.na + c.na }
+    else if last.li != undefined and c.li == undefined and c.ld == last.li
+      # insert immediately followed by delete becomes a noop.
+      if last.ld != undefined
+        # leave the delete part of the replace
+        delete last.li
+      else
+        dest.pop()
+    else if last.od != undefined and last.oi == undefined and
+        c.oi != undefined and c.od == undefined
+      last.oi = c.oi
+    else if c.lm != undefined and c.p[c.p.length-1] == c.lm
+      null # don't do anything
+    else
+      dest.push c
+  else
+    dest.push c
+
+json.compose = (op1, op2) ->
+  json.checkValidOp op1
+  json.checkValidOp op2
+
+  newOp = clone op1
+  json.append newOp, c for c in op2
+
+  newOp
+
+json.normalize = (op) ->
+  newOp = []
+  
+  op = [op] unless isArray op
+
+  for c in op
+    c.p ?= []
+    json.append newOp, c
+  
+  newOp
+
+# hax, copied from test/types/json. Apparently this is still the fastest way to deep clone an object, assuming
+# we have browser support for JSON.
+# http://jsperf.com/cloning-an-object/12
+clone = (o) -> JSON.parse(JSON.stringify o)
+
+json.commonPath = (p1, p2) ->
+  p1 = p1.slice()
+  p2 = p2.slice()
+  p1.unshift('data')
+  p2.unshift('data')
+  p1 = p1[...p1.length-1]
+  p2 = p2[...p2.length-1]
+  return -1 if p2.length == 0
+  i = 0
+  while p1[i] == p2[i] && i < p1.length
+    i++
+    if i == p2.length
+      return i-1
+  return
+
+# transform c so it applies to a document with otherC applied.
+json.transformComponent = (dest, c, otherC, type) ->
+  c = clone c
+  c.p.push(0) if c.na != undefined
+  otherC.p.push(0) if otherC.na != undefined
+
+  common = json.commonPath c.p, otherC.p
+  common2 = json.commonPath otherC.p, c.p
+
+  cplength = c.p.length
+  otherCplength = otherC.p.length
+
+  c.p.pop() if c.na != undefined # hax
+  otherC.p.pop() if otherC.na != undefined
+
+  if otherC.na
+    if common2? && otherCplength >= cplength && otherC.p[common2] == c.p[common2]
+      if c.ld != undefined
+        oc = clone otherC
+        oc.p = oc.p[cplength..]
+        c.ld = json.apply clone(c.ld), [oc]
+      else if c.od != undefined
+        oc = clone otherC
+        oc.p = oc.p[cplength..]
+        c.od = json.apply clone(c.od), [oc]
+    json.append dest, c
+    return dest
+
+  if common2? && otherCplength > cplength && c.p[common2] == otherC.p[common2]
+    # transform based on c
+    if c.ld != undefined
+      oc = clone otherC
+      oc.p = oc.p[cplength..]
+      c.ld = json.apply clone(c.ld), [oc]
+    else if c.od != undefined
+      oc = clone otherC
+      oc.p = oc.p[cplength..]
+      c.od = json.apply clone(c.od), [oc]
+
+
+  if common?
+    commonOperand = cplength == otherCplength
+    # transform based on otherC
+    if otherC.na != undefined
+      # this case is handled above due to icky path hax
+    else if otherC.si != undefined || otherC.sd != undefined
+      # String op vs string op - pass through to text type
+      if c.si != undefined || c.sd != undefined
+        throw new Error("must be a string?") unless commonOperand
+
+        # Convert an op component to a text op component
+        convert = (component) ->
+          newC = p:component.p[component.p.length - 1]
+          if component.si
+            newC.i = component.si
+          else
+            newC.d = component.sd
+          newC
+
+        tc1 = convert c
+        tc2 = convert otherC
+          
+        res = []
+        text._tc res, tc1, tc2, type
+        for tc in res
+          jc = { p: c.p[...common] }
+          jc.p.push(tc.p)
+          jc.si = tc.i if tc.i?
+          jc.sd = tc.d if tc.d?
+          json.append dest, jc
+        return dest
+    else if otherC.li != undefined && otherC.ld != undefined
+      if otherC.p[common] == c.p[common]
+        # noop
+        if !commonOperand
+          # we're below the deleted element, so -> noop
+          return dest
+        else if c.ld != undefined
+          # we're trying to delete the same element, -> noop
+          if c.li != undefined and type == 'left'
+            # we're both replacing one element with another. only one can
+            # survive!
+            c.ld = clone otherC.li
+          else
+            return dest
+    else if otherC.li != undefined
+      if c.li != undefined and c.ld == undefined and commonOperand and c.p[common] == otherC.p[common]
+        # in li vs. li, left wins.
+        if type == 'right'
+          c.p[common]++
+      else if otherC.p[common] <= c.p[common]
+        c.p[common]++
+
+      if c.lm != undefined
+        if commonOperand
+          # otherC edits the same list we edit
+          if otherC.p[common] <= c.lm
+            c.lm++
+          # changing c.from is handled above.
+    else if otherC.ld != undefined
+      if c.lm != undefined
+        if commonOperand
+          if otherC.p[common] == c.p[common]
+            # they deleted the thing we're trying to move
+            return dest
+          # otherC edits the same list we edit
+          p = otherC.p[common]
+          from = c.p[common]
+          to = c.lm
+          if p < to || (p == to && from < to)
+            c.lm--
+
+      if otherC.p[common] < c.p[common]
+        c.p[common]--
+      else if otherC.p[common] == c.p[common]
+        if otherCplength < cplength
+          # we're below the deleted element, so -> noop
+          return dest
+        else if c.ld != undefined
+          if c.li != undefined
+            # we're replacing, they're deleting. we become an insert.
+            delete c.ld
+          else
+            # we're trying to delete the same element, -> noop
+            return dest
+    else if otherC.lm != undefined
+      if c.lm != undefined and cplength == otherCplength
+        # lm vs lm, here we go!
+        from = c.p[common]
+        to = c.lm
+        otherFrom = otherC.p[common]
+        otherTo = otherC.lm
+        if otherFrom != otherTo
+          # if otherFrom == otherTo, we don't need to change our op.
+
+          # where did my thing go?
+          if from == otherFrom
+            # they moved it! tie break.
+            if type == 'left'
+              c.p[common] = otherTo
+              if from == to # ugh
+                c.lm = otherTo
+            else
+              return dest
+          else
+            # they moved around it
+            if from > otherFrom
+              c.p[common]--
+            if from > otherTo
+              c.p[common]++
+            else if from == otherTo
+              if otherFrom > otherTo
+                c.p[common]++
+                if from == to # ugh, again
+                  c.lm++
+
+            # step 2: where am i going to put it?
+            if to > otherFrom
+              c.lm--
+            else if to == otherFrom
+              if to > from
+                c.lm--
+            if to > otherTo
+              c.lm++
+            else if to == otherTo
+              # if we're both moving in the same direction, tie break
+              if (otherTo > otherFrom and to > from) or
+                 (otherTo < otherFrom and to < from)
+                if type == 'right'
+                  c.lm++
+              else
+                if to > from
+                  c.lm++
+                else if to == otherFrom
+                  c.lm--
+      else if c.li != undefined and c.ld == undefined and commonOperand
+        # li
+        from = otherC.p[common]
+        to = otherC.lm
+        p = c.p[common]
+        if p > from
+          c.p[common]--
+        if p > to
+          c.p[common]++
+      else
+        # ld, ld+li, si, sd, na, oi, od, oi+od, any li on an element beneath
+        # the lm
+        #
+        # i.e. things care about where their item is after the move.
+        from = otherC.p[common]
+        to = otherC.lm
+        p = c.p[common]
+        if p == from
+          c.p[common] = to
+        else
+          if p > from
+            c.p[common]--
+          if p > to
+            c.p[common]++
+          else if p == to
+            if from > to
+              c.p[common]++
+    else if otherC.oi != undefined && otherC.od != undefined
+      if c.p[common] == otherC.p[common]
+        if c.oi != undefined and commonOperand
+          # we inserted where someone else replaced
+          if type == 'right'
+            # left wins
+            return dest
+          else
+            # we win, make our op replace what they inserted
+            c.od = otherC.oi
+        else
+          # -> noop if the other component is deleting the same object (or any
+          # parent)
+          return dest
+    else if otherC.oi != undefined
+      if c.oi != undefined and c.p[common] == otherC.p[common]
+        # left wins if we try to insert at the same place
+        if type == 'left'
+          json.append dest, {p:c.p, od:otherC.oi}
+        else
+          return dest
+    else if otherC.od != undefined
+      if c.p[common] == otherC.p[common]
+        return dest if !commonOperand
+        if c.oi != undefined
+          delete c.od
+        else
+          return dest
+  
+  json.append dest, c
+  return dest
+
+if WEB?
+  exports.types ||= {}
+
+  # This is kind of awful - come up with a better way to hook this helper code up.
+  exports._bt(json, json.transformComponent, json.checkValidOp, json.append)
+
+  # [] is used to prevent closure from renaming types.text
+  exports.types.json = json
+else
+  module.exports = json
+
+  require('./helpers').bootstrapTransform(json, json.transformComponent, json.checkValidOp, json.append)
+

+ 38 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/simple.coffee

@@ -0,0 +1,38 @@
+# This is a really simple OT type. Its not compiled with the web client, but it could be.
+#
+# Its mostly included for demonstration purposes and its used in a lot of unit tests.
+#
+# This defines a really simple text OT type which only allows inserts. (No deletes).
+#
+# Ops look like:
+#   {position:#, text:"asdf"}
+#
+# Document snapshots look like:
+#   {str:string}
+
+module.exports =
+  # The name of the OT type. The type is stored in types[type.name]. The name can be
+  # used in place of the actual type in all the API methods.
+  name: 'simple'
+
+  # Create a new document snapshot
+  create: -> {str:""}
+
+  # Apply the given op to the document snapshot. Returns the new snapshot.
+  #
+  # The original snapshot should not be modified.
+  apply: (snapshot, op) ->
+    throw new Error 'Invalid position' unless 0 <= op.position <= snapshot.str.length
+
+    str = snapshot.str
+    str = str.slice(0, op.position) + op.text + str.slice(op.position)
+    {str}
+
+  # transform op1 by op2. Return transformed version of op1.
+  # sym describes the symmetry of the op. Its 'left' or 'right' depending on whether the
+  # op being transformed comes from the client or the server.
+  transform: (op1, op2, sym) ->
+    pos = op1.position
+    pos += op2.text.length if op2.position < pos or (op2.position == pos and sym is 'left')
+
+    return {position:pos, text:op1.text}

+ 32 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee

@@ -0,0 +1,32 @@
+# Text document API for text
+
+text = require './text' if typeof WEB is 'undefined'
+
+text.api =
+  provides: {text:true}
+
+  # The number of characters in the string
+  getLength: -> @snapshot.length
+
+  # Get the text contents of a document
+  getText: -> @snapshot
+
+  insert: (pos, text, callback) ->
+    op = [{p:pos, i:text}]
+    
+    @submitOp op, callback
+    op
+  
+  del: (pos, length, callback) ->
+    op = [{p:pos, d:@snapshot[pos...(pos + length)]}]
+
+    @submitOp op, callback
+    op
+  
+  _register: ->
+    @on 'remoteop', (op) ->
+      for component in op
+        if component.i != undefined
+          @emit 'insert', component.p, component.i
+        else
+          @emit 'delete', component.p, component.d

+ 58 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-api.js

@@ -0,0 +1,58 @@
+// Generated by CoffeeScript 1.4.0
+(function() {
+  var text;
+
+  if (typeof WEB === 'undefined') {
+    text = require('./text');
+  }
+
+  text.api = {
+    provides: {
+      text: true
+    },
+    getLength: function() {
+      return this.snapshot.length;
+    },
+    getText: function() {
+      return this.snapshot;
+    },
+    insert: function(pos, text, callback) {
+      var op;
+      op = [
+        {
+          p: pos,
+          i: text
+        }
+      ];
+      this.submitOp(op, callback);
+      return op;
+    },
+    del: function(pos, length, callback) {
+      var op;
+      op = [
+        {
+          p: pos,
+          d: this.snapshot.slice(pos, pos + length)
+        }
+      ];
+      this.submitOp(op, callback);
+      return op;
+    },
+    _register: function() {
+      return this.on('remoteop', function(op) {
+        var component, _i, _len, _results;
+        _results = [];
+        for (_i = 0, _len = op.length; _i < _len; _i++) {
+          component = op[_i];
+          if (component.i !== void 0) {
+            _results.push(this.emit('insert', component.p, component.i));
+          } else {
+            _results.push(this.emit('delete', component.p, component.d));
+          }
+        }
+        return _results;
+      });
+    }
+  };
+
+}).call(this);

+ 43 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-composable-api.coffee

@@ -0,0 +1,43 @@
+# Text document API for text
+
+if WEB?
+  type = exports.types['text-composable']
+else
+  type = require './text-composable'
+
+type.api =
+  provides: {'text':true}
+
+  # The number of characters in the string
+  'getLength': -> @snapshot.length
+
+  # Get the text contents of a document
+  'getText': -> @snapshot
+
+  'insert': (pos, text, callback) ->
+    op = type.normalize [pos, 'i':text, (@snapshot.length - pos)]
+    
+    @submitOp op, callback
+    op
+  
+  'del': (pos, length, callback) ->
+    op = type.normalize [pos, 'd':@snapshot[pos...(pos + length)], (@snapshot.length - pos - length)]
+
+    @submitOp op, callback
+    op
+
+  _register: ->
+    @on 'remoteop', (op) ->
+      pos = 0
+      for component in op
+        if typeof component is 'number'
+          pos += component
+        else if component.i != undefined
+          @emit 'insert', pos, component.i
+          pos += component.i.length
+        else
+          # delete
+          @emit 'delete', pos, component.d
+          # We don't increment pos, because the position
+          # specified is after the delete has happened.
+

+ 261 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-composable.coffee

@@ -0,0 +1,261 @@
+# An alternate composable implementation for text. This is much closer
+# to the implementation used by google wave.
+#
+# Ops are lists of components which iterate over the whole document.
+# Components are either:
+#   A number N: Skip N characters in the original document
+#   {i:'str'}:  Insert 'str' at the current position in the document
+#   {d:'str'}:  Delete 'str', which appears at the current position in the document
+#
+# Eg: [3, {i:'hi'}, 5, {d:'internet'}]
+#
+# Snapshots are strings.
+
+p = -> #require('util').debug
+i = -> #require('util').inspect
+
+exports = if WEB? then {} else module.exports
+
+exports.name = 'text-composable'
+
+exports.create = -> ''
+
+# -------- Utility methods
+
+checkOp = (op) ->
+  throw new Error('Op must be an array of components') unless Array.isArray(op)
+  last = null
+  for c in op
+    if typeof(c) == 'object'
+      throw new Error("Invalid op component: #{i c}") unless (c.i? && c.i.length > 0) or (c.d? && c.d.length > 0)
+    else
+      throw new Error('Op components must be objects or numbers') unless typeof(c) == 'number'
+      throw new Error('Skip components must be a positive number') unless c > 0
+      throw new Error('Adjacent skip components should be added') if typeof(last) == 'number'
+
+    last = c
+
+# Makes a function for appending components to a given op.
+# Exported for the randomOpGenerator.
+exports._makeAppend = makeAppend = (op) -> (component) ->
+  if component == 0 || component.i == '' || component.d == ''
+    return
+  else if op.length == 0
+    op.push component
+  else if typeof(component) == 'number' && typeof(op[op.length - 1]) == 'number'
+    op[op.length - 1] += component
+  else if component.i? && op[op.length - 1].i?
+    op[op.length - 1].i += component.i
+  else if component.d? && op[op.length - 1].d?
+    op[op.length - 1].d += component.d
+  else
+    op.push component
+  
+#  checkOp op
+
+# Makes 2 functions for taking components from the start of an op, and for peeking
+# at the next op that could be taken.
+makeTake = (op) ->
+  # The index of the next component to take
+  idx = 0
+  # The offset into the component
+  offset = 0
+
+  # Take up to length n from the front of op. If n is null, take the next
+  # op component. If indivisableField == 'd', delete components won't be separated.
+  # If indivisableField == 'i', insert components won't be separated.
+  take = (n, indivisableField) ->
+    return null if idx == op.length
+    #assert.notStrictEqual op.length, i, 'The op is too short to traverse the document'
+
+    if typeof(op[idx]) == 'number'
+      if !n? or op[idx] - offset <= n
+        c = op[idx] - offset
+        ++idx; offset = 0
+        c
+      else
+        offset += n
+        n
+    else
+      # Take from the string
+      field = if op[idx].i then 'i' else 'd'
+      c = {}
+      if !n? or op[idx][field].length - offset <= n or field == indivisableField
+        c[field] = op[idx][field][offset..]
+        ++idx; offset = 0
+      else
+        c[field] = op[idx][field][offset...(offset + n)]
+        offset += n
+      c
+  
+  peekType = () ->
+    op[idx]
+  
+  [take, peekType]
+
+# Find and return the length of an op component
+componentLength = (component) ->
+  if typeof(component) == 'number'
+    component
+  else if component.i?
+    component.i.length
+  else
+    component.d.length
+
+# Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
+# adjacent inserts and deletes.
+exports.normalize = (op) ->
+  newOp = []
+  append = makeAppend newOp
+  append component for component in op
+  newOp
+
+# Apply the op to the string. Returns the new string.
+exports.apply = (str, op) ->
+  p "Applying #{i op} to '#{str}'"
+  throw new Error('Snapshot should be a string') unless typeof(str) == 'string'
+  checkOp op
+
+  pos = 0
+  newDoc = []
+
+  for component in op
+    if typeof(component) == 'number'
+      throw new Error('The op is too long for this document') if component > str.length
+      newDoc.push str[...component]
+      str = str[component..]
+    else if component.i?
+      newDoc.push component.i
+    else
+      throw new Error("The deleted text '#{component.d}' doesn't match the next characters in the document '#{str[...component.d.length]}'") unless component.d == str[...component.d.length]
+      str = str[component.d.length..]
+  
+  throw new Error("The applied op doesn't traverse the entire document") unless '' == str
+
+  newDoc.join ''
+
+# transform op1 by op2. Return transformed version of op1.
+# op1 and op2 are unchanged by transform.
+exports.transform = (op, otherOp, side) ->
+  throw new Error "side (#{side} must be 'left' or 'right'" unless side == 'left' or side == 'right'
+
+  checkOp op
+  checkOp otherOp
+  newOp = []
+
+  append = makeAppend newOp
+  [take, peek] = makeTake op
+
+  for component in otherOp
+    if typeof(component) == 'number' # Skip
+      length = component
+      while length > 0
+        chunk = take(length, 'i')
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        append chunk
+        length -= componentLength chunk unless typeof(chunk) == 'object' && chunk.i?
+    else if component.i? # Insert
+      if side == 'left'
+        # The left insert should go first.
+        o = peek()
+        append take() if o?.i
+
+      # Otherwise, skip the inserted text.
+      append(component.i.length)
+    else # Delete.
+      #assert.ok component.d
+      length = component.d.length
+      while length > 0
+        chunk = take(length, 'i')
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        if typeof(chunk) == 'number'
+          length -= chunk
+        else if chunk.i?
+          append(chunk)
+        else
+          #assert.ok chunk.d
+          # The delete is unnecessary now.
+          length -= chunk.d.length
+  
+  # Append extras from op1
+  while (component = take())
+    throw new Error "Remaining fragments in the op: #{i component}" unless component?.i?
+    append component
+
+  newOp
+
+
+# Compose 2 ops into 1 op.
+exports.compose = (op1, op2) ->
+  p "COMPOSE #{i op1} + #{i op2}"
+  checkOp op1
+  checkOp op2
+
+  result = []
+
+  append = makeAppend result
+  [take, _] = makeTake op1
+
+  for component in op2
+    if typeof(component) == 'number' # Skip
+      length = component
+      while length > 0
+        chunk = take(length, 'd')
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        append chunk
+        length -= componentLength chunk unless typeof(chunk) == 'object' && chunk.d?
+
+    else if component.i? # Insert
+      append {i:component.i}
+
+    else # Delete
+      offset = 0
+      while offset < component.d.length
+        chunk = take(component.d.length - offset, 'd')
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        # If its delete, append it. If its skip, drop it and decrease length. If its insert, check the strings match, drop it and decrease length.
+        if typeof(chunk) == 'number'
+          append {d:component.d[offset...(offset + chunk)]}
+          offset += chunk
+        else if chunk.i?
+          throw new Error("The deleted text doesn't match the inserted text") unless component.d[offset...(offset + chunk.i.length)] == chunk.i
+          offset += chunk.i.length
+          # The ops cancel each other out.
+        else
+          # Delete
+          append chunk
+    
+  # Append extras from op1
+  while (component = take())
+    throw new Error "Trailing stuff in op1 #{i component}" unless component?.d?
+    append component
+
+  result
+  
+
+invertComponent = (c) ->
+  if typeof(c) == 'number'
+    c
+  else if c.i?
+    {d:c.i}
+  else
+    {i:c.d}
+
+# Invert an op
+exports.invert = (op) ->
+  result = []
+  append = makeAppend result
+
+  append(invertComponent component) for component in op
+  
+  result
+
+if window?
+  window.ot ||= {}
+  window.ot.types ||= {}
+  window.ot.types.text = exports
+

+ 89 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-tp2-api.coffee

@@ -0,0 +1,89 @@
+# Text document API for text-tp2
+
+if WEB?
+  type = exports.types['text-tp2']
+else
+  type = require './text-tp2'
+
+{_takeDoc:takeDoc, _append:append} = type
+
+appendSkipChars = (op, doc, pos, maxlength) ->
+  while (maxlength == undefined || maxlength > 0) and pos.index < doc.data.length
+    part = takeDoc doc, pos, maxlength, true
+    maxlength -= part.length if maxlength != undefined and typeof part is 'string'
+    append op, (part.length || part)
+
+type['api'] =
+  'provides': {'text':true}
+
+  # The number of characters in the string
+  'getLength': -> @snapshot.charLength
+
+  # Flatten a document into a string
+  'getText': ->
+    strings = (elem for elem in @snapshot.data when typeof elem is 'string')
+    strings.join ''
+
+  'insert': (pos, text, callback) ->
+    pos = 0 if pos == undefined
+
+    op = []
+    docPos = {index:0, offset:0}
+
+    appendSkipChars op, @snapshot, docPos, pos
+    append op, {'i':text}
+    appendSkipChars op, @snapshot, docPos
+    
+    @submitOp op, callback
+    op
+  
+  'del': (pos, length, callback) ->
+    op = []
+    docPos = {index:0, offset:0}
+
+    appendSkipChars op, @snapshot, docPos, pos
+    
+    while length > 0
+      part = takeDoc @snapshot, docPos, length, true
+      if typeof part is 'string'
+        append op, {'d':part.length}
+        length -= part.length
+      else
+        append op, part
+    
+    appendSkipChars op, @snapshot, docPos
+
+    @submitOp op, callback
+    op
+
+  '_register': ->
+    # Interpret recieved ops + generate more detailed events for them
+    @on 'remoteop', (op, snapshot) ->
+      textPos = 0
+      docPos = {index:0, offset:0}
+
+      for component in op
+        if typeof component is 'number'
+          # Skip
+          remainder = component
+          while remainder > 0
+            part = takeDoc snapshot, docPos, remainder
+            if typeof part is 'string'
+              textPos += part.length
+            remainder -= part.length || part
+        else if component.i != undefined
+          # Insert
+          if typeof component.i is 'string'
+            @emit 'insert', textPos, component.i
+            textPos += component.i.length
+        else
+          # Delete
+          remainder = component.d
+          while remainder > 0
+            part = takeDoc snapshot, docPos, remainder
+            if typeof part is 'string'
+              @emit 'delete', textPos, part
+            remainder -= part.length || part
+
+      return
+

+ 322 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text-tp2.coffee

@@ -0,0 +1,322 @@
+# A TP2 implementation of text, following this spec:
+# http://code.google.com/p/lightwave/source/browse/trunk/experimental/ot/README
+#
+# A document is made up of a string and a set of tombstones inserted throughout
+# the string. For example, 'some ', (2 tombstones), 'string'.
+#
+# This is encoded in a document as: {s:'some string', t:[5, -2, 6]}
+#
+# Ops are lists of components which iterate over the whole document.
+# Components are either:
+#   N:         Skip N characters in the original document
+#   {i:'str'}: Insert 'str' at the current position in the document
+#   {i:N}:     Insert N tombstones at the current position in the document
+#   {d:N}:     Delete (tombstone) N characters at the current position in the document
+#
+# Eg: [3, {i:'hi'}, 5, {d:8}]
+#
+# Snapshots are lists with characters and tombstones. Characters are stored in strings
+# and adjacent tombstones are flattened into numbers.
+#
+# Eg, the document: 'Hello .....world' ('.' denotes tombstoned (deleted) characters)
+# would be represented by a document snapshot of ['Hello ', 5, 'world']
+
+type =
+  name: 'text-tp2'
+  tp2: true
+  create: -> {charLength:0, totalLength:0, positionCache:[], data:[]}
+  serialize: (doc) ->
+    throw new Error 'invalid doc snapshot' unless doc.data
+    doc.data
+  deserialize: (data) ->
+    doc = type.create()
+    doc.data = data
+    
+    for component in data
+      if typeof component is 'string'
+        doc.charLength += component.length
+        doc.totalLength += component.length
+      else
+        doc.totalLength += component
+    
+    doc
+
+
+checkOp = (op) ->
+  throw new Error('Op must be an array of components') unless Array.isArray(op)
+  last = null
+  for c in op
+    if typeof(c) == 'object'
+      if c.i != undefined
+        throw new Error('Inserts must insert a string or a +ive number') unless (typeof(c.i) == 'string' and c.i.length > 0) or (typeof(c.i) == 'number' and c.i > 0)
+      else if c.d != undefined
+        throw new Error('Deletes must be a +ive number') unless typeof(c.d) == 'number' and c.d > 0
+      else
+        throw new Error('Operation component must define .i or .d')
+    else
+      throw new Error('Op components must be objects or numbers') unless typeof(c) == 'number'
+      throw new Error('Skip components must be a positive number') unless c > 0
+      throw new Error('Adjacent skip components should be combined') if typeof(last) == 'number'
+
+    last = c
+
+# Take the next part from the specified position in a document snapshot.
+# position = {index, offset}. It will be updated.
+type._takeDoc = takeDoc = (doc, position, maxlength, tombsIndivisible) ->
+  throw new Error 'Operation goes past the end of the document' if position.index >= doc.data.length
+
+  part = doc.data[position.index]
+  # peel off data[0]
+  result = if typeof(part) == 'string'
+    if maxlength != undefined
+      part[position.offset...(position.offset + maxlength)]
+    else
+      part[position.offset...]
+  else
+    if maxlength == undefined or tombsIndivisible
+      part - position.offset
+    else
+      Math.min(maxlength, part - position.offset)
+  
+  resultLen = result.length || result
+
+  if (part.length || part) - position.offset > resultLen
+    position.offset += resultLen
+  else
+    position.index++
+    position.offset = 0
+  
+  result
+
+# Append a part to the end of a document
+type._appendDoc = appendDoc = (doc, p) ->
+  return if p == 0 or p == ''
+
+  if typeof p is 'string'
+    doc.charLength += p.length
+    doc.totalLength += p.length
+  else
+    doc.totalLength += p
+
+  data = doc.data
+  if data.length == 0
+    data.push p
+  else if typeof(data[data.length - 1]) == typeof(p)
+    data[data.length - 1] += p
+  else
+    data.push p
+  return
+
+# Apply the op to the document. The document is not modified in the process.
+type.apply = (doc, op) ->
+  unless doc.totalLength != undefined and doc.charLength != undefined and doc.data.length != undefined
+    throw new Error('Snapshot is invalid')
+
+  checkOp op
+
+  newDoc = type.create()
+  position = {index:0, offset:0}
+
+  for component in op
+    if typeof(component) is 'number'
+      remainder = component
+      while remainder > 0
+        part = takeDoc doc, position, remainder
+        
+        appendDoc newDoc, part
+        remainder -= part.length || part
+
+    else if component.i != undefined
+      appendDoc newDoc, component.i
+    else if component.d != undefined
+      remainder = component.d
+      while remainder > 0
+        part = takeDoc doc, position, remainder
+        remainder -= part.length || part
+      appendDoc newDoc, component.d
+  
+  newDoc
+
+# Append an op component to the end of the specified op.
+# Exported for the randomOpGenerator.
+type._append = append = (op, component) ->
+  if component == 0 || component.i == '' || component.i == 0 || component.d == 0
+    return
+  else if op.length == 0
+    op.push component
+  else
+    last = op[op.length - 1]
+    if typeof(component) == 'number' && typeof(last) == 'number'
+      op[op.length - 1] += component
+    else if component.i != undefined && last.i? && typeof(last.i) == typeof(component.i)
+      last.i += component.i
+    else if component.d != undefined && last.d?
+      last.d += component.d
+    else
+      op.push component
+  
+# Makes 2 functions for taking components from the start of an op, and for peeking
+# at the next op that could be taken.
+makeTake = (op) ->
+  # The index of the next component to take
+  index = 0
+  # The offset into the component
+  offset = 0
+
+  # Take up to length maxlength from the op. If maxlength is not defined, there is no max.
+  # If insertsIndivisible is true, inserts (& insert tombstones) won't be separated.
+  #
+  # Returns null when op is fully consumed.
+  take = (maxlength, insertsIndivisible) ->
+    return null if index == op.length
+
+    e = op[index]
+    if typeof((current = e)) == 'number' or typeof((current = e.i)) == 'number' or (current = e.d) != undefined
+      if !maxlength? or current - offset <= maxlength or (insertsIndivisible and e.i != undefined)
+        # Return the rest of the current element.
+        c = current - offset
+        ++index; offset = 0
+      else
+        offset += maxlength
+        c = maxlength
+      if e.i != undefined then {i:c} else if e.d != undefined then {d:c} else c
+    else
+      # Take from the inserted string
+      if !maxlength? or e.i.length - offset <= maxlength or insertsIndivisible
+        result = {i:e.i[offset..]}
+        ++index; offset = 0
+      else
+        result = {i:e.i[offset...offset + maxlength]}
+        offset += maxlength
+      result
+  
+  peekType = -> op[index]
+  
+  [take, peekType]
+
+# Find and return the length of an op component
+componentLength = (component) ->
+  if typeof(component) == 'number'
+    component
+  else if typeof(component.i) == 'string'
+    component.i.length
+  else
+    # This should work because c.d and c.i must be +ive.
+    component.d or component.i
+
+# Normalize an op, removing all empty skips and empty inserts / deletes. Concatenate
+# adjacent inserts and deletes.
+type.normalize = (op) ->
+  newOp = []
+  append newOp, component for component in op
+  newOp
+
+# This is a helper method to transform and prune. goForwards is true for transform, false for prune.
+transformer = (op, otherOp, goForwards, side) ->
+  checkOp op
+  checkOp otherOp
+  newOp = []
+
+  [take, peek] = makeTake op
+
+  for component in otherOp
+    length = componentLength component
+
+    if component.i != undefined # Insert text or tombs
+      if goForwards # transform - insert skips over inserted parts
+        if side == 'left'
+          # The left insert should go first.
+          append newOp, take() while peek()?.i != undefined
+
+        # In any case, skip the inserted text.
+        append newOp, length
+
+      else # Prune. Remove skips for inserts.
+        while length > 0
+          chunk = take length, true
+
+          throw new Error 'The transformed op is invalid' unless chunk != null
+          throw new Error 'The transformed op deletes locally inserted characters - it cannot be purged of the insert.' if chunk.d != undefined
+
+          if typeof chunk is 'number'
+            length -= chunk
+          else
+            append newOp, chunk
+
+    else # Skip or delete
+      while length > 0
+        chunk = take length, true
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        append newOp, chunk
+        length -= componentLength chunk unless chunk.i
+  
+  # Append extras from op1
+  while (component = take())
+    throw new Error "Remaining fragments in the op: #{component}" unless component.i != undefined
+    append newOp, component
+
+  newOp
+
+# transform op1 by op2. Return transformed version of op1.
+# op1 and op2 are unchanged by transform.
+# side should be 'left' or 'right', depending on if op1.id <> op2.id. 'left' == client op.
+type.transform = (op, otherOp, side) ->
+  throw new Error "side (#{side}) should be 'left' or 'right'" unless side == 'left' or side == 'right'
+  transformer op, otherOp, true, side
+
+# Prune is the inverse of transform.
+type.prune = (op, otherOp) -> transformer op, otherOp, false
+
+# Compose 2 ops into 1 op.
+type.compose = (op1, op2) ->
+  return op2 if op1 == null or op1 == undefined
+
+  checkOp op1
+  checkOp op2
+
+  result = []
+
+  [take, _] = makeTake op1
+
+  for component in op2
+
+    if typeof(component) == 'number' # Skip
+      # Just copy from op1.
+      length = component
+      while length > 0
+        chunk = take length
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        append result, chunk
+        length -= componentLength chunk
+
+    else if component.i != undefined # Insert
+      append result, {i:component.i}
+
+    else # Delete
+      length = component.d
+      while length > 0
+        chunk = take length
+        throw new Error('The op traverses more elements than the document has') unless chunk != null
+
+        chunkLength = componentLength chunk
+        if chunk.i != undefined
+          append result, {i:chunkLength}
+        else
+          append result, {d:chunkLength}
+
+        length -= chunkLength
+    
+  # Append extras from op1
+  while (component = take())
+    throw new Error "Remaining fragments in op1: #{component}" unless component.i != undefined
+    append result, component
+
+  result
+
+if WEB?
+  exports.types['text-tp2'] = type
+else
+  module.exports = type
+

+ 209 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text.coffee

@@ -0,0 +1,209 @@
+# A simple text implementation
+#
+# Operations are lists of components.
+# Each component either inserts or deletes at a specified position in the document.
+#
+# Components are either:
+#  {i:'str', p:100}: Insert 'str' at position 100 in the document
+#  {d:'str', p:100}: Delete 'str' at position 100 in the document
+#
+# Components in an operation are executed sequentially, so the position of components
+# assumes previous components have already executed.
+#
+# Eg: This op:
+#   [{i:'abc', p:0}]
+# is equivalent to this op:
+#   [{i:'a', p:0}, {i:'b', p:1}, {i:'c', p:2}]
+
+# NOTE: The global scope here is shared with other sharejs files when built with closure.
+# Be careful what ends up in your namespace.
+
+text = {}
+
+text.name = 'text'
+
+text.create = -> ''
+
+strInject = (s1, pos, s2) -> s1[...pos] + s2 + s1[pos..]
+
+checkValidComponent = (c) ->
+  throw new Error 'component missing position field' if typeof c.p != 'number'
+
+  i_type = typeof c.i
+  d_type = typeof c.d
+  throw new Error 'component needs an i or d field' unless (i_type == 'string') ^ (d_type == 'string')
+
+  throw new Error 'position cannot be negative' unless c.p >= 0
+
+checkValidOp = (op) ->
+  checkValidComponent(c) for c in op
+  true
+
+text.apply = (snapshot, op) ->
+  checkValidOp op
+  for component in op
+    if component.i?
+      snapshot = strInject snapshot, component.p, component.i
+    else
+      deleted = snapshot[component.p...(component.p + component.d.length)]
+      throw new Error "Delete component '#{component.d}' does not match deleted text '#{deleted}'" unless component.d == deleted
+      snapshot = snapshot[...component.p] + snapshot[(component.p + component.d.length)..]
+  
+  snapshot
+
+
+# Exported for use by the random op generator.
+#
+# For simplicity, this version of append does not compress adjacent inserts and deletes of
+# the same text. It would be nice to change that at some stage.
+text._append = append = (newOp, c) ->
+  return if c.i == '' or c.d == ''
+  if newOp.length == 0
+    newOp.push c
+  else
+    last = newOp[newOp.length - 1]
+
+    # Compose the insert into the previous insert if possible
+    if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length)
+      newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p}
+    else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length)
+      newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p}
+    else
+      newOp.push c
+
+text.compose = (op1, op2) ->
+  checkValidOp op1
+  checkValidOp op2
+
+  newOp = op1.slice()
+  append newOp, c for c in op2
+
+  newOp
+
+# Attempt to compress the op components together 'as much as possible'.
+# This implementation preserves order and preserves create/delete pairs.
+text.compress = (op) -> text.compose [], op
+
+text.normalize = (op) ->
+  newOp = []
+  
+  # Normalize should allow ops which are a single (unwrapped) component:
+  # {i:'asdf', p:23}.
+  # There's no good way to test if something is an array:
+  # http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
+  # so this is probably the least bad solution.
+  op = [op] if op.i? or op.p?
+
+  for c in op
+    c.p ?= 0
+    append newOp, c
+  
+  newOp
+
+# This helper method transforms a position by an op component.
+#
+# If c is an insert, insertAfter specifies whether the transform
+# is pushed after the insert (true) or before it (false).
+#
+# insertAfter is optional for deletes.
+transformPosition = (pos, c, insertAfter) ->
+  if c.i?
+    if c.p < pos || (c.p == pos && insertAfter)
+      pos + c.i.length
+    else
+      pos
+  else
+    # I think this could also be written as: Math.min(c.p, Math.min(c.p - otherC.p, otherC.d.length))
+    # but I think its harder to read that way, and it compiles using ternary operators anyway
+    # so its no slower written like this.
+    if pos <= c.p
+      pos
+    else if pos <= c.p + c.d.length
+      c.p
+    else
+      pos - c.d.length
+
+# Helper method to transform a cursor position as a result of an op.
+#
+# Like transformPosition above, if c is an insert, insertAfter specifies whether the cursor position
+# is pushed after an insert (true) or before it (false).
+text.transformCursor = (position, op, side) ->
+  insertAfter = side == 'right'
+  position = transformPosition position, c, insertAfter for c in op
+  position
+
+# Transform an op component by another op component. Asymmetric.
+# The result will be appended to destination.
+#
+# exported for use in JSON type
+text._tc = transformComponent = (dest, c, otherC, side) ->
+  checkValidOp [c]
+  checkValidOp [otherC]
+
+  if c.i?
+    append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
+
+  else # Delete
+    if otherC.i? # delete vs insert
+      s = c.d
+      if c.p < otherC.p
+        append dest, {d:s[...otherC.p - c.p], p:c.p}
+        s = s[(otherC.p - c.p)..]
+      if s != ''
+        append dest, {d:s, p:c.p + otherC.i.length}
+
+    else # Delete vs delete
+      if c.p >= otherC.p + otherC.d.length
+        append dest, {d:c.d, p:c.p - otherC.d.length}
+      else if c.p + c.d.length <= otherC.p
+        append dest, c
+      else
+        # They overlap somewhere.
+        newC = {d:'', p:c.p}
+        if c.p < otherC.p
+          newC.d = c.d[...(otherC.p - c.p)]
+        if c.p + c.d.length > otherC.p + otherC.d.length
+          newC.d += c.d[(otherC.p + otherC.d.length - c.p)..]
+
+        # This is entirely optional - just for a check that the deleted
+        # text in the two ops matches
+        intersectStart = Math.max c.p, otherC.p
+        intersectEnd = Math.min c.p + c.d.length, otherC.p + otherC.d.length
+        cIntersect = c.d[intersectStart - c.p...intersectEnd - c.p]
+        otherIntersect = otherC.d[intersectStart - otherC.p...intersectEnd - otherC.p]
+        throw new Error 'Delete ops delete different text in the same region of the document' unless cIntersect == otherIntersect
+
+        if newC.d != ''
+          # This could be rewritten similarly to insert v delete, above.
+          newC.p = transformPosition newC.p, otherC
+          append dest, newC
+  
+  dest
+
+invertComponent = (c) ->
+  if c.i?
+    {d:c.i, p:c.p}
+  else
+    {i:c.d, p:c.p}
+
+# No need to use append for invert, because the components won't be able to
+# cancel with one another.
+text.invert = (op) -> (invertComponent c for c in op.slice().reverse())
+
+
+if WEB?
+  exports.types ||= {}
+
+  # This is kind of awful - come up with a better way to hook this helper code up.
+  bootstrapTransform(text, transformComponent, checkValidOp, append)
+
+  # [] is used to prevent closure from renaming types.text
+  exports.types.text = text
+else
+  module.exports = text
+
+  # The text type really shouldn't need this - it should be possible to define
+  # an efficient transform function by making a sort of transform map and passing each
+  # op component through it.
+  require('./helpers').bootstrapTransform(text, transformComponent, checkValidOp, append)
+

+ 239 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/text.js

@@ -0,0 +1,239 @@
+// Generated by CoffeeScript 1.4.0
+(function() {
+  var append, checkValidComponent, checkValidOp, invertComponent, strInject, text, transformComponent, transformPosition;
+
+  text = {};
+
+  text.name = 'text';
+
+  text.create = function() {
+    return '';
+  };
+
+  strInject = function(s1, pos, s2) {
+    return s1.slice(0, pos) + s2 + s1.slice(pos);
+  };
+
+  checkValidComponent = function(c) {
+    var d_type, i_type;
+    if (typeof c.p !== 'number') {
+      throw new Error('component missing position field');
+    }
+    i_type = typeof c.i;
+    d_type = typeof c.d;
+    if (!((i_type === 'string') ^ (d_type === 'string'))) {
+      throw new Error('component needs an i or d field');
+    }
+    if (!(c.p >= 0)) {
+      throw new Error('position cannot be negative');
+    }
+  };
+
+  checkValidOp = function(op) {
+    var c, _i, _len;
+    for (_i = 0, _len = op.length; _i < _len; _i++) {
+      c = op[_i];
+      checkValidComponent(c);
+    }
+    return true;
+  };
+
+  text.apply = function(snapshot, op) {
+    var component, deleted, _i, _len;
+    checkValidOp(op);
+    for (_i = 0, _len = op.length; _i < _len; _i++) {
+      component = op[_i];
+      if (component.i != null) {
+        snapshot = strInject(snapshot, component.p, component.i);
+      } else {
+        deleted = snapshot.slice(component.p, component.p + component.d.length);
+        if (component.d !== deleted) {
+          throw new Error("Delete component '" + component.d + "' does not match deleted text '" + deleted + "'");
+        }
+        snapshot = snapshot.slice(0, component.p) + snapshot.slice(component.p + component.d.length);
+      }
+    }
+    return snapshot;
+  };
+
+  text._append = append = function(newOp, c) {
+    var last, _ref, _ref1;
+    if (c.i === '' || c.d === '') {
+      return;
+    }
+    if (newOp.length === 0) {
+      return newOp.push(c);
+    } else {
+      last = newOp[newOp.length - 1];
+      if ((last.i != null) && (c.i != null) && (last.p <= (_ref = c.p) && _ref <= (last.p + last.i.length))) {
+        return newOp[newOp.length - 1] = {
+          i: strInject(last.i, c.p - last.p, c.i),
+          p: last.p
+        };
+      } else if ((last.d != null) && (c.d != null) && (c.p <= (_ref1 = last.p) && _ref1 <= (c.p + c.d.length))) {
+        return newOp[newOp.length - 1] = {
+          d: strInject(c.d, last.p - c.p, last.d),
+          p: c.p
+        };
+      } else {
+        return newOp.push(c);
+      }
+    }
+  };
+
+  text.compose = function(op1, op2) {
+    var c, newOp, _i, _len;
+    checkValidOp(op1);
+    checkValidOp(op2);
+    newOp = op1.slice();
+    for (_i = 0, _len = op2.length; _i < _len; _i++) {
+      c = op2[_i];
+      append(newOp, c);
+    }
+    return newOp;
+  };
+
+  text.compress = function(op) {
+    return text.compose([], op);
+  };
+
+  text.normalize = function(op) {
+    var c, newOp, _i, _len, _ref;
+    newOp = [];
+    if ((op.i != null) || (op.p != null)) {
+      op = [op];
+    }
+    for (_i = 0, _len = op.length; _i < _len; _i++) {
+      c = op[_i];
+      if ((_ref = c.p) == null) {
+        c.p = 0;
+      }
+      append(newOp, c);
+    }
+    return newOp;
+  };
+
+  transformPosition = function(pos, c, insertAfter) {
+    if (c.i != null) {
+      if (c.p < pos || (c.p === pos && insertAfter)) {
+        return pos + c.i.length;
+      } else {
+        return pos;
+      }
+    } else {
+      if (pos <= c.p) {
+        return pos;
+      } else if (pos <= c.p + c.d.length) {
+        return c.p;
+      } else {
+        return pos - c.d.length;
+      }
+    }
+  };
+
+  text.transformCursor = function(position, op, side) {
+    var c, insertAfter, _i, _len;
+    insertAfter = side === 'right';
+    for (_i = 0, _len = op.length; _i < _len; _i++) {
+      c = op[_i];
+      position = transformPosition(position, c, insertAfter);
+    }
+    return position;
+  };
+
+  text._tc = transformComponent = function(dest, c, otherC, side) {
+    var cIntersect, intersectEnd, intersectStart, newC, otherIntersect, s;
+    checkValidOp([c]);
+    checkValidOp([otherC]);
+    if (c.i != null) {
+      append(dest, {
+        i: c.i,
+        p: transformPosition(c.p, otherC, side === 'right')
+      });
+    } else {
+      if (otherC.i != null) {
+        s = c.d;
+        if (c.p < otherC.p) {
+          append(dest, {
+            d: s.slice(0, otherC.p - c.p),
+            p: c.p
+          });
+          s = s.slice(otherC.p - c.p);
+        }
+        if (s !== '') {
+          append(dest, {
+            d: s,
+            p: c.p + otherC.i.length
+          });
+        }
+      } else {
+        if (c.p >= otherC.p + otherC.d.length) {
+          append(dest, {
+            d: c.d,
+            p: c.p - otherC.d.length
+          });
+        } else if (c.p + c.d.length <= otherC.p) {
+          append(dest, c);
+        } else {
+          newC = {
+            d: '',
+            p: c.p
+          };
+          if (c.p < otherC.p) {
+            newC.d = c.d.slice(0, otherC.p - c.p);
+          }
+          if (c.p + c.d.length > otherC.p + otherC.d.length) {
+            newC.d += c.d.slice(otherC.p + otherC.d.length - c.p);
+          }
+          intersectStart = Math.max(c.p, otherC.p);
+          intersectEnd = Math.min(c.p + c.d.length, otherC.p + otherC.d.length);
+          cIntersect = c.d.slice(intersectStart - c.p, intersectEnd - c.p);
+          otherIntersect = otherC.d.slice(intersectStart - otherC.p, intersectEnd - otherC.p);
+          if (cIntersect !== otherIntersect) {
+            throw new Error('Delete ops delete different text in the same region of the document');
+          }
+          if (newC.d !== '') {
+            newC.p = transformPosition(newC.p, otherC);
+            append(dest, newC);
+          }
+        }
+      }
+    }
+    return dest;
+  };
+
+  invertComponent = function(c) {
+    if (c.i != null) {
+      return {
+        d: c.i,
+        p: c.p
+      };
+    } else {
+      return {
+        i: c.d,
+        p: c.p
+      };
+    }
+  };
+
+  text.invert = function(op) {
+    var c, _i, _len, _ref, _results;
+    _ref = op.slice().reverse();
+    _results = [];
+    for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+      c = _ref[_i];
+      _results.push(invertComponent(c));
+    }
+    return _results;
+  };
+
+  if (typeof WEB !== "undefined" && WEB !== null) {
+    exports.types || (exports.types = {});
+    bootstrapTransform(text, transformComponent, checkValidOp, append);
+    exports.types.text = text;
+  } else {
+    module.exports = text;
+    require('./helpers').bootstrapTransform(text, transformComponent, checkValidOp, append);
+  }
+
+}).call(this);

+ 11 - 0
services/web/public/coffee/ide/editor/sharejs/vendor/types/web-prelude.coffee

@@ -0,0 +1,11 @@
+# This is included at the top of each compiled type file for the web.
+
+`/**
+   @const
+   @type {boolean}
+*/
+var WEB = true;
+`
+
+exports = window['sharejs']
+

+ 281 - 0
services/web/public/coffee/ide/file-tree/FileTreeManager.coffee

@@ -0,0 +1,281 @@
+define [
+	"ide/file-tree/directives/fileEntity"
+	"ide/file-tree/directives/draggable"
+	"ide/file-tree/directives/droppable"
+	"ide/file-tree/controllers/FileTreeController"
+	"ide/file-tree/controllers/FileTreeEntityController"
+	"ide/file-tree/controllers/FileTreeFolderController"
+	"ide/file-tree/controllers/FileTreeRootFolderController"
+], () ->
+	class FileTreeManager
+		constructor: (@ide, @$scope) ->
+			@$scope.$on "project:joined", =>
+				@loadRootFolder()
+				@loadDeletedDocs()
+				@$scope.$emit "file-tree:initialized"
+
+			@_bindToSocketEvents()
+
+		_bindToSocketEvents: () ->
+			@ide.socket.on "reciveNewDoc", (parent_folder_id, doc) =>
+				parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder
+				@$scope.$apply () ->
+					parent_folder.children.push {
+						name: doc.name
+						id:   doc._id
+						type: "doc"
+					}
+
+			@ide.socket.on "reciveNewFile", (parent_folder_id, file) =>
+				parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder
+				@$scope.$apply () ->
+					parent_folder.children.push {
+						name: file.name
+						id:   file._id
+						type: "file"
+					}
+
+			@ide.socket.on "reciveNewFolder", (parent_folder_id, folder) =>
+				parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder
+				@$scope.$apply () ->
+					parent_folder.children.push {
+						name: folder.name
+						id:   folder._id
+						type: "folder"
+						children: []
+					}
+
+			@ide.socket.on "reciveEntityRename", (entity_id, name) =>
+				entity = @findEntityById(entity_id)
+				return if !entity?
+				@$scope.$apply () ->
+					entity.name = name
+
+			@ide.socket.on "removeEntity", (entity_id) =>
+				entity = @findEntityById(entity_id)
+				return if !entity?
+				@$scope.$apply () =>
+					@_deleteEntityFromScope entity
+
+			@ide.socket.on "reciveEntityMove", (entity_id, folder_id) =>
+				entity = @findEntityById(entity_id)
+				folder = @findEntityById(folder_id)
+				console.log "Got recive ENTITY", entity_id, folder_id, entity, folder
+				@$scope.$apply () =>
+					@_moveEntityInScope(entity, folder)
+
+		selectEntity: (entity) ->
+			@selected_entity_id = entity.id # For reselecting after a reconnect
+			@ide.fileTreeManager.forEachEntity (entity) ->
+				entity.selected = false
+			entity.selected = true
+
+		findSelectedEntity: () ->
+			selected = null
+			@forEachEntity (entity) ->
+				selected = entity if entity.selected
+			return selected
+
+		findEntityById: (id, options = {}) ->
+			return @$scope.rootFolder if @$scope.rootFolder.id == id
+
+			entity = @_findEntityByIdInFolder @$scope.rootFolder, id
+			return entity if entity?
+
+			if options.includeDeleted
+				for entity in @$scope.deletedDocs
+					return entity if entity.id == id
+
+			return null
+
+		_findEntityByIdInFolder: (folder, id) ->
+			for entity in folder.children or []
+				if entity.id == id
+					return entity
+				else if entity.children?
+					result = @_findEntityByIdInFolder(entity, id)
+					return result if result?
+
+			return null
+
+		findEntityByPath: (path) ->
+			@_findEntityByPathInFolder @$scope.rootFolder, path
+
+		_findEntityByPathInFolder: (folder, path) ->
+			parts = path.split("/")
+			name = parts.shift()
+			rest = parts.join("/")
+			
+			if name == "."
+				return @_findEntityByPathInFolder(folder, rest)
+
+			for entity in folder.children
+				if entity.name == name
+					if rest == ""
+						return entity
+					else if entity.type == "folder"
+						return @_findEntityByPathInFolder(entity, rest)
+			return null
+
+		forEachEntity: (callback = (entity, parent_folder) ->) ->
+			@_forEachEntityInFolder(@$scope.rootFolder, callback)
+
+			for entity in @$scope.deletedDocs or []
+				callback(entity)
+
+		_forEachEntityInFolder: (folder, callback) ->
+			for entity in folder.children or []
+				callback(entity, folder)
+				if entity.children?
+					@_forEachEntityInFolder(entity, callback)
+
+		getEntityPath: (entity) ->
+			@_getEntityPathInFolder @$scope.rootFolder, entity
+
+		_getEntityPathInFolder: (folder, entity) ->
+			for child in folder.children or []
+				if child == entity
+					return entity.name
+				else if child.type == "folder"
+					path = @_getEntityPathInFolder(child, entity)
+					if path?
+						return child.name + "/" + path
+			return null
+
+		getRootDocDirname: () ->
+			rootDoc = @findEntityById @$scope.project.rootDoc_id
+			return if !rootDoc?
+			path = @getEntityPath(rootDoc)
+			return if !path?
+			return path.split("/").slice(0, -1).join("/")
+
+		# forEachFolder: (callback) ->
+		# 	@forEachEntity (entity) ->
+		# 		if entity.type == "folder"
+		# 			callback(entity)
+
+		loadRootFolder: () ->
+			@$scope.rootFolder = @_parseFolder(@$scope.project.rootFolder[0])
+
+		_parseFolder: (rawFolder) ->
+			folder = {
+				name: rawFolder.name
+				id:   rawFolder._id
+				type: "folder"
+				children: []
+				selected: (rawFolder._id == @selected_entity_id)
+			}
+
+			for doc in rawFolder.docs or []
+				folder.children.push {
+					name: doc.name
+					id:   doc._id
+					type: "doc"
+					selected: (doc._id == @selected_entity_id)
+				}
+
+			for file in rawFolder.fileRefs or []
+				folder.children.push {
+					name: file.name
+					id:   file._id
+					type: "file"
+					selected: (file._id == @selected_entity_id)
+				}
+
+			for childFolder in rawFolder.folders or []
+				folder.children.push @_parseFolder(childFolder)
+
+			return folder
+
+		loadDeletedDocs: () ->
+			@$scope.deletedDocs = []
+			for doc in @$scope.project.deletedDocs
+				@$scope.deletedDocs.push {
+					name: doc.name
+					id:   doc._id
+					type: "doc"
+					deleted: true
+				}
+
+		getCurrentFolder: () ->
+			# Return the root folder if nothing is selected
+			@_getCurrentFolder(@$scope.rootFolder) or @$scope.rootFolder
+
+		_getCurrentFolder: (startFolder = @$scope.rootFolder) ->
+			for entity in startFolder.children or []
+				# 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"
+					result = @_getCurrentFolder(entity)
+					return result if result?
+
+			return null
+
+		createDoc: (name, parent_folder = @getCurrentFolder()) ->
+			# We'll wait for the socket.io notification to actually
+			# add the doc for us.
+			@ide.$http.post "/project/#{@ide.project_id}/doc", {
+				name: name,
+				parent_folder_id: parent_folder?.id
+				_csrf: window.csrfToken
+			}
+
+		createFolder: (name, parent_folder = @getCurrentFolder()) ->
+			# We'll wait for the socket.io notification to actually
+			# add the folder for us.
+			return @ide.$http.post "/project/#{@ide.project_id}/folder", {
+				name: name,
+				parent_folder_id: parent_folder?.id
+				_csrf: window.csrfToken
+			}
+
+		renameEntity: (entity, name, callback = (error) ->) ->
+			return if entity.name == name
+			entity.name = name
+			return @ide.$http.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/rename", {
+				name: name,
+				_csrf: window.csrfToken
+			}
+
+		deleteEntity: (entity, callback = (error) ->) ->
+			# We'll wait for the socket.io notification to 
+			# delete from scope.
+			return @ide.$http {
+				method: "DELETE"
+				url:    "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}"
+				headers:
+					"X-Csrf-Token": window.csrfToken
+			}
+
+		moveEntity: (entity, parent_folder, callback = (error) ->) ->
+			@_moveEntityInScope(entity, parent_folder)
+			return @ide.$http.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/move", {
+				folder_id: parent_folder.id
+				_csrf: window.csrfToken
+			}
+
+		_deleteEntityFromScope: (entity, options = { moveToDeleted: true }) ->
+			parent_folder = null
+			@forEachEntity (possible_entity, folder) ->
+				if possible_entity == entity
+					parent_folder = folder
+
+			if parent_folder?
+				index = parent_folder.children.indexOf(entity)
+				if index > -1
+					parent_folder.children.splice(index, 1)
+
+			if entity.type == "doc" and options.moveToDeleted
+				entity.deleted = true
+				@$scope.deletedDocs.push entity
+
+		_moveEntityInScope: (entity, parent_folder) ->
+			return if entity in parent_folder.children
+			@_deleteEntityFromScope(entity, moveToDeleted: false)
+			parent_folder.children.push(entity)

+ 113 - 0
services/web/public/coffee/ide/file-tree/controllers/FileTreeController.coffee

@@ -0,0 +1,113 @@
+define [
+	"base"
+], (App) ->
+	App.controller "FileTreeController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
+		$scope.openNewDocModal = () ->
+			$modal.open(
+				templateUrl: "newDocModalTemplate"
+				controller:  "NewDocModalController"
+				resolve: {
+					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+				}
+			)
+
+		$scope.openNewFolderModal = () ->
+			$modal.open(
+				templateUrl: "newFolderModalTemplate"
+				controller:  "NewFolderModalController"
+				resolve: {
+					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+				}
+			)
+
+		$scope.openUploadFileModal = () ->
+			$modal.open(
+				templateUrl: "uploadFileModalTemplate"
+				controller:  "UploadFileModalController"
+				scope: $scope
+				resolve: {
+					parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
+				}
+			)
+
+		$scope.orderByFoldersFirst = (entity) ->
+			return '0' if entity.type == "folder"
+			return '1'
+
+		$scope.startRenamingSelected = () ->
+			$scope.$broadcast "rename:selected"
+
+		$scope.openDeleteModalForSelected = () ->
+			$scope.$broadcast "delete:selected"
+	]
+
+	App.controller "NewDocModalController", [
+		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
+		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+			$scope.inputs = 
+				name: "name.tex"
+			$scope.state =
+				inflight: false
+
+			$modalInstance.opened.then () ->
+				$timeout () ->
+					$scope.$broadcast "open"
+				, 200
+
+			$scope.create = () ->
+				$scope.state.inflight = true
+				ide.fileTreeManager
+					.createDoc($scope.inputs.name, parent_folder)
+					.success () ->
+						$scope.state.inflight = false
+						$modalInstance.close()
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+	]
+
+	App.controller "NewFolderModalController", [
+		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
+		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+			$scope.inputs = 
+				name: "name"
+			$scope.state =
+				inflight: false
+
+			$modalInstance.opened.then () ->
+				$timeout () ->
+					$scope.$broadcast "open"
+				, 200
+
+			$scope.create = () ->
+				$scope.state.inflight = true
+				ide.fileTreeManager
+					.createFolder($scope.inputs.name, parent_folder)
+					.success () ->
+						$scope.state.inflight = false
+						$modalInstance.close()
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+	]
+
+	App.controller "UploadFileModalController", [
+		"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
+		($scope,   ide,   $modalInstance,   $timeout,   parent_folder) ->
+			console.log "PArent folder", parent_folder
+			$scope.parent_folder_id = parent_folder?.id
+
+			uploadCount = 0
+			$scope.onUpload = () ->
+				uploadCount++
+
+			$scope.onComplete = (error, name, response) ->
+				$timeout (() ->
+					uploadCount--
+					if uploadCount == 0 and response? and response.success
+						$modalInstance.close("done")
+				), 250
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+	]

+ 49 - 0
services/web/public/coffee/ide/file-tree/controllers/FileTreeEntityController.coffee

@@ -0,0 +1,49 @@
+define [
+	"base"
+], (App) ->
+	App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
+		$scope.select = () ->
+			ide.fileTreeManager.selectEntity($scope.entity)
+			$scope.$emit "entity:selected", $scope.entity
+
+		$scope.inputs =
+			name: $scope.entity.name
+
+		$scope.startRenaming = () ->
+			$scope.entity.renaming = true
+
+		$scope.finishRenaming = () ->
+			delete $scope.entity.renaming
+			ide.fileTreeManager.renameEntity($scope.entity, $scope.inputs.name)
+
+		$scope.$on "rename:selected", () ->
+			$scope.startRenaming() if $scope.entity.selected
+
+		$scope.openDeleteModal = () ->
+			$modal.open(
+				templateUrl: "deleteEntityModalTemplate"
+				controller:  "DeleteEntityModalController"
+				scope: $scope
+			)
+
+		$scope.$on "delete:selected", () ->
+			$scope.openDeleteModal() if $scope.entity.selected
+	]
+
+	App.controller "DeleteEntityModalController", [
+		"$scope", "ide", "$modalInstance",
+		($scope,   ide,   $modalInstance) ->
+			$scope.state =
+				inflight: false
+
+			$scope.delete = () ->
+				$scope.state.inflight = true
+				ide.fileTreeManager
+					.deleteEntity($scope.entity)
+					.success () ->
+						$scope.state.inflight = false
+						$modalInstance.close()
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+	]

+ 51 - 0
services/web/public/coffee/ide/file-tree/controllers/FileTreeFolderController.coffee

@@ -0,0 +1,51 @@
+define [
+	"base"
+], (App) ->
+	App.controller "FileTreeFolderController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
+		$scope.expanded = $.localStorage("folder.#{$scope.entity.id}.expanded") or false
+
+		$scope.toggleExpanded = () ->
+			$scope.expanded = !$scope.expanded
+			$.localStorage("folder.#{$scope.entity.id}.expanded", $scope.expanded)
+
+		$scope.onDrop = (events, ui) ->
+			source = $(ui.draggable).scope().entity
+			return if !source?
+			ide.fileTreeManager.moveEntity(source, $scope.entity)
+
+		$scope.orderByFoldersFirst = (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.
+			return '0' if entity.type == "folder"
+			return '1'
+
+		$scope.openNewDocModal = () ->
+			$modal.open(
+				templateUrl: "newDocModalTemplate"
+				controller:  "NewDocModalController"
+				resolve: {
+					parent_folder: () -> $scope.entity
+				}
+			)
+
+		$scope.openNewFolderModal = () ->
+			$modal.open(
+				templateUrl: "newFolderModalTemplate"
+				controller:  "NewFolderModalController"
+				resolve: {
+					parent_folder: () -> $scope.entity
+				}
+			)
+
+		$scope.openUploadFileModal = () ->
+			$scope.project_id = ide.project_id
+			$modal.open(
+				templateUrl: "uploadFileModalTemplate"
+				controller:  "UploadFileModalController"
+				scope: $scope
+				resolve: {
+					parent_folder: () -> $scope.entity
+				}
+			)
+	]

+ 13 - 0
services/web/public/coffee/ide/file-tree/controllers/FileTreeRootFolderController.coffee

@@ -0,0 +1,13 @@
+define [
+	"base"
+], (App) ->
+	App.controller "FileTreeRootFolderController", ["$scope", "ide", ($scope, ide) ->
+		console.log "CREATING FileTreeRootFolderController"
+		rootFolder = $scope.rootFolder
+		console.log "ROOT FOLDER", rootFolder
+		$scope.onDrop = (events, ui) ->
+			source = $(ui.draggable).scope().entity
+			console.log "DROPPED INTO ROOT", source, rootFolder
+			return if !source?
+			ide.fileTreeManager.moveEntity(source, rootFolder)
+	]

+ 14 - 0
services/web/public/coffee/ide/file-tree/directives/draggable.coffee

@@ -0,0 +1,14 @@
+define [
+	"base"
+], (App) ->
+	App.directive "draggable", () ->
+		return {
+			link: (scope, element, attrs) ->
+				scope.$watch attrs.draggable, (draggable) ->
+					if draggable
+						element.draggable
+							delay: 250
+							opacity: 0.7
+							helper: "clone"
+							scroll: true
+		}

+ 17 - 0
services/web/public/coffee/ide/file-tree/directives/droppable.coffee

@@ -0,0 +1,17 @@
+define [
+	"base"
+], (App) ->
+	App.directive "droppable", () ->
+		return {
+			scope: {
+				onDropCallback: "="
+			}
+			link: (scope, element, attrs) ->
+				scope.$watch attrs.droppable, (droppable) ->
+					if droppable
+						element.droppable
+							greedy: true
+							hoverClass: "droppable-hover"
+							accept: attrs.accept
+							drop: scope.onDropCallback
+		}

+ 23 - 0
services/web/public/coffee/ide/file-tree/directives/fileEntity.coffee

@@ -0,0 +1,23 @@
+define [
+	"base"
+], (App) ->
+	App.directive "fileEntity", ["RecursionHelper", (RecursionHelper) ->
+		return {
+			restrict: "E"
+			scope: {
+				entity: "="
+				permissions: "="
+			}
+			templateUrl: "entityListItemTemplate"
+			compile: (element) ->
+				RecursionHelper.compile element, (scope, element, attrs, ctrl) ->
+					# Don't freak out if we're already in an apply callback
+					scope.$originalApply = scope.$apply
+					scope.$apply = (fn = () ->) ->
+						phase = @$root.$$phase
+						if (phase == '$apply' || phase == '$digest')
+							fn()
+						else
+							@$originalApply(fn);
+		}
+	]

+ 71 - 0
services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee

@@ -0,0 +1,71 @@
+define [
+	"libs/md5"
+], () ->
+	class OnlineUsersManager
+		constructor: (@ide, @$scope) ->
+			@$scope.onlineUsers = {}
+			@$scope.onlineUserCursorHighlights = {}
+
+			@$scope.$watch "editor.cursorPosition", (position) =>
+				if position?
+					@sendCursorPositionUpdate()
+
+			@ide.socket.on "clientTracking.clientUpdated", (client) =>
+				if client.id != @ide.socket.socket.sessionid # Check it's not me!
+					@$scope.$apply () =>
+						@$scope.onlineUsers[client.id] = client
+						@updateCursorHighlights()
+
+			@ide.socket.on "clientTracking.clientDisconnected", (client_id) =>
+				@$scope.$apply () =>
+					delete @$scope.onlineUsers[client_id]
+					@updateCursorHighlights()
+
+		updateCursorHighlights: () ->
+			console.log "UPDATING CURSOR HIGHLIGHTS"
+			@$scope.onlineUserCursorHighlights = {}
+			for client_id, client of @$scope.onlineUsers
+				doc_id = client.doc_id
+				continue if !doc_id?
+				@$scope.onlineUserCursorHighlights[doc_id] ||= []
+				@$scope.onlineUserCursorHighlights[doc_id].push {
+					label: client.name
+					cursor:
+						row: client.row
+						column: client.column
+					hue: @getHueForUserId(client.user_id)
+				}
+
+		UPDATE_INTERVAL: 500
+		sendCursorPositionUpdate: () ->
+			console.log "SENDING CURSOR POSITION UPDATE", @$scope.editor.cursorPosition
+			if !@cursorUpdateTimeout?
+				@cursorUpdateTimeout = setTimeout ()=>
+					position = @$scope.editor.cursorPosition
+					doc_id   = @$scope.editor.open_doc_id
+
+					@ide.socket.emit "clientTracking.updatePosition", {
+						row: position.row
+						column: position.column
+						doc_id: doc_id
+					}
+
+					delete @cursorUpdateTimeout
+				, @UPDATE_INTERVAL
+
+		OWN_HUE: 200 # We will always appear as this color to ourselves
+		ANONYMOUS_HUE: 100
+		getHueForUserId: (user_id) ->
+			if !user_id? or user_id == "anonymous-user"
+				return @ANONYMOUS_HUE
+
+			if window.user.id == user_id
+				return @OWN_HUE
+
+			hash = CryptoJS.MD5(user_id)
+			hue = parseInt(hash.toString().slice(0,8), 16) % 320
+			# Avoid 20 degrees either side of the personal hue
+			if hue > @OWNER_HUE - 20
+				hue = hue + 40
+			return hue
+

+ 20 - 0
services/web/public/coffee/ide/pdf/PdfManager.coffee

@@ -0,0 +1,20 @@
+define [
+	"ide/pdf/controllers/PdfController"
+	"ide/pdf/directives/pdfJs"
+], () ->
+	class PdfManager
+		constructor: (@ide, @$scope) ->
+			@$scope.pdf =
+				url: null # Pdf Url
+				error: false # Server error
+				timeout: false # Server timed out
+				failure: false # PDF failed to compile
+				compiling: false
+				uncompiled: true
+				logEntries: []
+				logEntryAnnotations: {}
+				rawLog: ""
+				view: null # 'pdf' 'logs'
+				showRawLog: false
+				highlights: []
+				position: null

+ 277 - 0
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -0,0 +1,277 @@
+define [
+	"base"
+	"libs/latex-log-parser"
+], (App, LogParser) ->
+	App.controller "PdfController", ["$scope", "$http", "ide", "$modal", "synctex", ($scope, $http, ide, $modal, synctex) ->
+		autoCompile = true
+		$scope.$on "doc:opened", () ->
+			return if !autoCompile
+			autoCompile = false
+			$scope.recompile(isAutoCompile: true)
+
+		sendCompileRequest = (options = {}) ->
+			url = "/project/#{$scope.project_id}/compile"
+			if options.isAutoCompile
+				url += "?auto_compile=true"
+			return $http.post url, {
+				settingsOverride:
+					rootDoc_id: options.rootDocOverride_id or null
+				_csrf: window.csrfToken
+			}
+
+		parseCompileResponse = (response) ->
+			# Reset everything
+			$scope.pdf.error      = false
+			$scope.pdf.timedout   = false
+			$scope.pdf.failure    = false
+			$scope.pdf.uncompiled = false
+			$scope.pdf.url        = null
+
+			if response.status == "timedout"
+				$scope.pdf.timedout = true
+			else if response.status == "autocompile-backoff"
+				$scope.pdf.uncompiled = true
+			else if response.status == "failure"
+				$scope.pdf.failure = true
+				fetchLogs()
+			else if response.status == "success"
+				$scope.pdf.url = "/project/#{$scope.project_id}/output/output.pdf?cache_bust=#{Date.now()}"
+				fetchLogs()
+
+			IGNORE_FILES = ["output.fls", "output.fdb_latexmk"]
+			$scope.pdf.outputFiles = []
+			for file in response.outputFiles
+				if IGNORE_FILES.indexOf(file.path) == -1
+					# Turn 'output.blg' into 'blg file'.
+					if file.path.match(/^output\./)
+						file.name = "#{file.path.replace(/^output\./, "")} file"
+					else
+						file.name = file.path
+					$scope.pdf.outputFiles.push file
+
+		fetchLogs = () ->
+			$http.get "/project/#{$scope.project_id}/output/output.log"
+				.success (log) ->
+					$scope.pdf.rawLog = log
+					logEntries = LogParser.parse(log, ignoreDuplicates: true)
+					$scope.pdf.logEntries = logEntries
+					$scope.pdf.logEntries.all = logEntries.errors.concat(logEntries.warnings).concat(logEntries.typesetting)
+
+					$scope.pdf.logEntryAnnotations = {}
+					for entry in logEntries.all
+						entry.file = normalizeFilePath(entry.file)
+
+						entity = ide.fileTreeManager.findEntityByPath(entry.file)
+						if entity?
+							$scope.pdf.logEntryAnnotations[entity.id] ||= []
+							$scope.pdf.logEntryAnnotations[entity.id].push {
+								row: entry.line - 1
+								type: if entry.level == "error" then "error" else "warning"
+								text: entry.message
+							}
+
+				.error () ->
+					$scope.pdf.logEntries = []
+					$scope.pdf.rawLog = ""
+
+		getRootDocOverride_id = () ->
+			doc = ide.editorManager.getCurrentDocValue()
+			return null if !doc?
+			for line in doc.split("\n")
+				match = line.match /(.*)\\documentclass/
+				if match and !match[1].match /%/
+					return ide.editorManager.getCurrentDocId()
+			return null
+
+		normalizeFilePath = (path) ->
+			path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
+			path = path.replace(/^\/compile\//, "")
+
+			rootDocDirname = ide.fileTreeManager.getRootDocDirname()
+			if rootDocDirname?
+				path = path.replace(/^\.\//, rootDocDirname + "/")
+
+			return path
+
+		$scope.recompile = (options = {}) ->
+			console.log "Recompiling", options
+			return if $scope.pdf.compiling
+			$scope.pdf.compiling = true
+
+			options.rootDocOverride_id = getRootDocOverride_id()
+
+			sendCompileRequest(options)
+				.success (data) ->
+					$scope.pdf.view = "pdf"
+					$scope.pdf.compiling = false
+					parseCompileResponse(data)
+				.error () ->
+					$scope.pdf.compiling = false
+					$scope.pdf.error = true
+
+		$scope.clearCache = () ->
+			$http {
+				url: "/project/#{$scope.project_id}/output"
+				method: "DELETE"
+				headers:
+					"X-Csrf-Token": window.csrfToken
+			}
+
+		$scope.toggleLogs = () ->
+			if !$scope.pdf.view? or $scope.pdf.view == "pdf"
+				$scope.pdf.view = "logs"
+			else
+				$scope.pdf.view = "pdf"
+
+		$scope.showPdf = () ->
+			$scope.pdf.view = "pdf"
+
+		$scope.toggleRawLog = () ->
+			$scope.pdf.showRawLog = !$scope.pdf.showRawLog
+
+		$scope.openOutputFile = (file) ->
+			window.open("/project/#{$scope.project_id}/output/#{file.path}")
+
+		$scope.openClearCacheModal = () ->
+			modalInstance = $modal.open(
+				templateUrl: "clearCacheModalTemplate"
+				controller: "ClearCacheModalController"
+				scope: $scope
+			)
+
+		$scope.syncToCode = (position) ->
+			console.log "SYNCING VIA DBL CLICK", position
+			synctex
+				.syncToCode(position)
+				.then (data) ->
+					{doc, line} = data
+					ide.editorManager.openDoc(doc, gotoLine: line)
+
+	]
+
+	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
+		synctex =
+			syncToPdf: (cursorPosition) ->
+				deferred = $q.defer()
+
+				doc_id = ide.editorManager.getCurrentDocId()
+				if !doc_id?
+					deferred.reject()
+					return deferred.promise
+				doc = ide.fileTreeManager.findEntityById(doc_id)
+				if !doc?
+					deferred.reject()
+					return deferred.promise
+				path = ide.fileTreeManager.getEntityPath(doc)
+				if !path?
+					deferred.reject()
+					return deferred.promise
+				
+				# If the root file is folder/main.tex, then synctex sees the
+				# path as folder/./main.tex
+				rootDocDirname = ide.fileTreeManager.getRootDocDirname()
+				if rootDocDirname? and rootDocDirname != ""
+					path = path.replace(RegExp("^#{rootDocDirname}"), "#{rootDocDirname}/.")
+
+				{row, column} = cursorPosition
+
+				$http({
+						url: "/project/#{ide.project_id}/sync/code", 
+						method: "GET",
+						params: {
+							file: path
+							line: row + 1
+							column: column
+						}
+					})
+					.success (data) ->
+						deferred.resolve(data.pdf or [])
+					.error (error) ->
+						deferred.reject(error)
+
+				return deferred.promise
+
+			syncToCode: (position, options = {}) ->
+				deferred = $q.defer()
+				if !position?
+					deferred.reject()
+					return deferred.promise
+
+				# It's not clear exactly where we should sync to if it wasn't directly
+				# clicked on, but a little bit down from the very top seems best.
+				if options.includeVisualOffset
+					position.offset.top = position.offset.top + 80
+
+				$http({
+						url: "/project/#{ide.project_id}/sync/pdf", 
+						method: "GET",
+						params: {
+							page: position.page + 1
+							h: position.offset.left.toFixed(2)
+							v: position.offset.top.toFixed(2)
+						}
+					})
+					.success (data) ->
+						if data.code? and data.code.length > 0
+							doc = ide.fileTreeManager.findEntityByPath(data.code[0].file)
+							return if !doc?
+							deferred.resolve({doc: doc, line: data.code[0].line})
+					.error (error) ->
+						deferred.reject(error)
+
+				return deferred.promise
+
+		return synctex
+	]
+
+	App.controller "PdfSynctexController", ["$scope", "synctex", "ide", ($scope, synctex, ide) ->
+		$scope.showControls = true
+		$scope.$on "layout:pdf:resize", (event, data) ->
+			if data.east.initClosed
+				$scope.showControls = false
+			else
+				$scope.showControls = true
+			setTimeout () ->
+				$scope.$digest()
+			, 0
+
+		$scope.syncToPdf = () ->
+			synctex
+				.syncToPdf($scope.editor.cursorPosition)
+				.then (highlights) ->
+					$scope.pdf.highlights = highlights
+
+		$scope.syncToCode = () ->
+			synctex
+				.syncToCode($scope.pdf.position, includeVisualOffset: true)
+				.then (data) ->
+					{doc, line} = data
+					console.log "OPENING DOC", doc, line
+					ide.editorManager.openDoc(doc, gotoLine: line)
+	]
+
+	App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
+		$scope.openInEditor = (entry) ->
+			console.log "OPENING", entry.file, entry.line
+			entity = ide.fileTreeManager.findEntityByPath(entry.file)
+			return if !entity? or entity.type != "doc"
+			if entry.line?
+				line = entry.line
+			ide.editorManager.openDoc(entity, gotoLine: line)
+	]
+
+	App.controller 'ClearCacheModalController', ["$scope", "$modalInstance", ($scope, $modalInstance) ->
+		$scope.state =
+			inflight: false
+
+		$scope.clear = () ->
+			$scope.state.inflight = true
+			$scope
+				.clearCache()
+				.then () ->
+					$scope.state.inflight = false
+					$modalInstance.close()
+
+		$scope.cancel = () ->
+			$modalInstance.dismiss('cancel')
+	]

+ 196 - 0
services/web/public/coffee/ide/pdf/directives/pdfJs.coffee

@@ -0,0 +1,196 @@
+define [
+	"base"
+	"libs/pdfListView/PdfListView"
+	"libs/pdfListView/TextLayerBuilder"
+	"libs/pdfListView/AnnotationsLayerBuilder"
+	"libs/pdfListView/HighlightsLayerBuilder"
+	"text!libs/pdfListView/TextLayer.css"
+	"text!libs/pdfListView/AnnotationsLayer.css"
+	"text!libs/pdfListView/HighlightsLayer.css"
+], (
+	App
+	PDFListView
+	TextLayerBuilder
+	AnnotationsLayerBuilder
+	HighlightsLayerBuilder
+	textLayerCss
+	annotationsLayerCss
+	highlightsLayerCss
+) ->
+	if PDFJS?
+		PDFJS.workerSrc = "#{window.sharelatex.pdfJsWorkerPath}"
+
+	style = $("<style/>")
+	style.text(textLayerCss + "\n" + annotationsLayerCss + "\n" + highlightsLayerCss)
+	$("body").append(style)
+
+	App.directive "pdfjs", ["$timeout", ($timeout) ->
+		return {
+			scope: {
+				"pdfSrc": "="
+				"highlights": "="
+				"position": "="
+				"dblClickCallback": "="
+			}
+			link: (scope, element, attrs) ->
+				pdfListView = new PDFListView element.find(".pdfjs-viewer")[0],
+					textLayerBuilder: TextLayerBuilder
+					annotationsLayerBuilder: AnnotationsLayerBuilder
+					highlightsLayerBuilder: HighlightsLayerBuilder
+					ondblclick: (e) -> onDoubleClick(e)
+					logLevel: PDFListView.Logger.DEBUG
+				pdfListView.listView.pageWidthOffset = 20
+				pdfListView.listView.pageHeightOffset = 20
+
+				scope.loading = false
+
+				onProgress = (progress) ->
+					scope.$apply () ->
+						scope.progress = Math.floor(progress.loaded/progress.total*100)
+						console.log "PROGRESS", scope.progress, progress.loaded, progress.total
+
+				initializedPosition = false
+				initializePosition = () ->
+					return if initializedPosition
+					initializedPosition = true
+
+					if (scale = $.localStorage("pdf.scale"))?
+						pdfListView.setScaleMode(scale.scaleMode, scale.scale)
+					else
+						pdfListView.setToFitWidth()
+
+					if (position = $.localStorage("pdf.position.#{attrs.key}"))
+						pdfListView.setPdfPosition(position)
+
+					scope.position = pdfListView.getPdfPosition(true)
+
+					$(window).unload () =>
+						$.localStorage "pdf.scale", {
+							scaleMode: pdfListView.getScaleMode()
+							scale: pdfListView.getScale()
+						}
+						$.localStorage "pdf.position.#{attrs.key}", pdfListView.getPdfPosition()
+
+				flashControls = () ->
+					scope.flashControls = true
+					$timeout () ->
+						scope.flashControls = false
+					, 1000
+
+				element.find(".pdfjs-viewer").scroll () ->
+					console.log "UPDATING POSITION", pdfListView.getPdfPosition(true)
+					scope.position = pdfListView.getPdfPosition(true)
+
+				onDoubleClick = (e) ->
+					scope.dblClickCallback?(page: e.page, offset: { top: e.y, left: e.x })
+
+				scope.$watch "pdfSrc", (url) ->
+					if url
+						scope.loading = true
+						scope.progress = 0
+
+						pdfListView
+							.loadPdf(url, onProgress)
+							.then () ->
+								scope.$apply () ->
+									scope.loading = false
+									delete scope.progress
+									initializePosition()
+									flashControls()
+
+				scope.$watch "highlights", (areas) ->
+					console.log "UPDATING HIGHLIGHTS", areas
+					return if !areas?
+					highlights = for area in areas or []
+						{
+							page: area.page - 1
+							highlight:
+								left: area.h
+								top: area.v
+								height: area.height
+								width: area.width
+						}
+
+					if highlights.length > 0
+						first = highlights[0]
+						pdfListView.setPdfPosition({
+							page: first.page
+							offset:
+								left: first.highlight.left
+								top: first.highlight.top - 80
+						}, true)
+
+					pdfListView.clearHighlights()
+					pdfListView.setHighlights(highlights, true)
+					
+					setTimeout () =>
+						pdfListView.clearHighlights()
+					, 1000
+
+				scope.fitToHeight = () ->
+					pdfListView.setToFitHeight()
+
+				scope.fitToWidth = () ->
+					pdfListView.setToFitWidth()
+
+				scope.zoomIn = () ->
+					scale = pdfListView.getScale()
+					pdfListView.setScale(scale * 1.2)
+
+				scope.zoomOut = () ->
+					scale = pdfListView.getScale()
+					pdfListView.setScale(scale / 1.2)
+
+				if attrs.resizeOn?
+					for event in attrs.resizeOn.split(",")
+						scope.$on event, () ->
+							pdfListView.onResize()
+
+			template: """
+				<div class="pdfjs-viewer"></div>
+				<div class="pdfjs-controls" ng-class="{'flash': flashControls }">
+					<div class="btn-group">
+						<a href
+							class="btn btn-info btn-lg"
+							ng-click="fitToWidth()"
+							tooltip="Fit to Width"
+							tooltip-append-to-body="true"
+							tooltip-placement="bottom"
+						>
+							<i class="fa fa-fw fa-arrows-h"></i>
+						</a>
+						<a href
+							class="btn btn-info btn-lg"
+							ng-click="fitToHeight()"
+							tooltip="Fit to Height"
+							tooltip-append-to-body="true"
+							tooltip-placement="bottom"
+						>
+							<i class="fa fa-fw fa-arrows-v"></i>
+						</a>
+						<a href
+							class="btn btn-info btn-lg"
+							ng-click="zoomIn()"
+							tooltip="Zoom In"
+							tooltip-append-to-body="true"
+							tooltip-placement="bottom"
+						>
+							<i class="fa fa-fw fa-search-plus"></i>
+						</a>
+						<a href
+							class="btn btn-info btn-lg"
+							ng-click="zoomOut()"
+							tooltip="Zoom Out"
+							tooltip-append-to-body="true"
+							tooltip-placement="bottom"
+						>
+							<i class="fa fa-fw fa-search-minus"></i>
+						</a>
+					</div>
+				</div>
+				<div class="progress-thin" ng-show="loading">
+					<div class="progress-bar" ng-style="{ 'width': progress + '%' }"></div>
+				</div>
+			"""
+		}
+	]

+ 19 - 0
services/web/public/coffee/ide/permissions/PermissionsManager.coffee

@@ -0,0 +1,19 @@
+define [], () ->
+	class PermissionsManager
+		constructor: (@ide, @$scope) ->
+			@$scope.$watch "permissionsLevel", (permissionsLevel) =>
+				@$scope.permissions =
+					read:  false
+					write: false
+					admin: false
+				if permissionsLevel?
+					if permissionsLevel == "readOnly"
+						@$scope.permissions.read = true
+					else if permissionsLevel == "readAndWrite"
+						@$scope.permissions.read = true
+						@$scope.permissions.write = true
+					else if permissionsLevel == "owner"
+						@$scope.permissions.read = true
+						@$scope.permissions.write = true
+						@$scope.permissions.admin = true
+

+ 37 - 0
services/web/public/coffee/ide/services/ide.coffee

@@ -0,0 +1,37 @@
+define [
+	"base"
+], (App) ->
+	# We create and provide this as service so that we can access the global ide
+	# from within other parts of the angular app.
+	App.factory "ide", ["$http", "$modal", ($http, $modal) ->
+		ide = {}
+		ide.$http = $http
+
+		ide.pushEvent = () ->
+			#console.log "PUSHING EVENT STUB", arguments
+
+		ide.reportError = () ->
+			console.log "REPORTING ERROR STUB", arguments
+
+		ide.showGenericServerErrorMessage = () ->
+			console.error "GENERIC SERVER ERROR MESSAGE STUB"
+
+		ide.showGenericMessageModal = (title, message) ->
+			$modal.open {
+				templateUrl: "genericMessageModalTemplate"
+				controller:  "GenericMessageModalController"
+				resolve:
+					title:   -> title
+					message: -> message
+			}
+
+		return ide
+	]
+
+	App.controller "GenericMessageModalController", ["$scope", "$modalInstance", "title", "message", ($scope, $modalInstance, title, message) ->
+		$scope.title = title
+		$scope.message = message
+
+		$scope.done = () ->
+			$modalInstance.close()
+	]

+ 26 - 0
services/web/public/coffee/ide/settings/controllers/ProjectNameController.coffee

@@ -0,0 +1,26 @@
+define [
+	"base"
+], (App) ->
+	App.controller "ProjectNameController", ["$scope", "settings", "ide", ($scope, settings, ide) ->
+		$scope.state =
+			renaming: false
+		$scope.inputs = {}
+
+		$scope.startRenaming = () ->
+			$scope.inputs.name = $scope.project.name
+			$scope.state.renaming = true
+			$scope.$emit "project:rename:start"
+
+		$scope.finishRenaming = () ->
+			$scope.project.name = $scope.inputs.name
+			settings.saveProjectSettings({name: $scope.inputs.name})
+			$scope.state.renaming = false
+
+		ide.socket.on "projectNameUpdated", (name) ->
+			$scope.$apply () ->
+				$scope.project.name = name
+
+		$scope.$watch "project.name", (name) ->
+			if name?
+				window.document.title = name + " - Online LaTeX Editor ShareLaTeX"
+	]

+ 51 - 0
services/web/public/coffee/ide/settings/controllers/SettingsController.coffee

@@ -0,0 +1,51 @@
+define [
+	"base"
+], (App) ->
+	App.controller "SettingsController", ["$scope", "settings", "ide", ($scope, settings, ide) ->
+		if $scope.settings.mode not in ["default", "vim", "emacs"]
+			$scope.settings.mode = "default"
+
+		$scope.$watch "settings.theme", (theme, oldTheme) =>
+			if theme != oldTheme
+				settings.saveSettings({theme: theme})
+
+		$scope.$watch "settings.fontSize", (fontSize, oldFontSize) =>
+			if fontSize != oldFontSize
+				settings.saveSettings({fontSize: parseInt(fontSize, 10)})
+
+		$scope.$watch "settings.mode", (mode, oldMode) =>
+			if mode != oldMode
+				settings.saveSettings({mode: mode})
+
+		$scope.$watch "settings.autoComplete", (autoComplete, oldAutoComplete) =>
+			if autoComplete != oldAutoComplete
+				settings.saveSettings({autoComplete: autoComplete})
+
+		$scope.$watch "settings.pdfViewer", (pdfViewer, oldPdfViewer) =>
+			if pdfViewer != oldPdfViewer
+				settings.saveSettings({pdfViewer: pdfViewer})
+
+		$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
+			return if @ignoreUpdates
+			if oldLanguage? and language != oldLanguage
+				settings.saveProjectSettings({spellCheckLanguage: language})
+				# Also set it as the default for the user
+				settings.saveSettings({spellCheckLanguage: language})
+
+		$scope.$watch "project.compiler", (compiler, oldCompiler) =>
+			return if @ignoreUpdates
+			if oldCompiler? and compiler != oldCompiler
+				settings.saveProjectSettings({compiler: compiler})
+
+		ide.socket.on "compilerUpdated", (compiler) =>
+			@ignoreUpdates = true
+			$scope.$apply () =>
+				$scope.project.compiler = compiler
+			delete @ignoreUpdates
+
+		ide.socket.on "spellCheckLanguageUpdated", (languageCode) =>
+			@ignoreUpdates = true
+			$scope.$apply () =>
+				$scope.project.spellCheckLanguage = languageCode
+			delete @ignoreUpdates
+	]

+ 7 - 0
services/web/public/coffee/ide/settings/index.coffee

@@ -0,0 +1,7 @@
+define [
+	"ide/settings/services/settings"
+	"ide/settings/controllers/SettingsController"
+	"ide/settings/controllers/ProjectNameController"
+
+], () ->
+			

+ 14 - 0
services/web/public/coffee/ide/settings/services/settings.coffee

@@ -0,0 +1,14 @@
+define [
+	"base"
+], (App) ->
+	App.factory "settings", ["ide", (ide) ->
+		return {
+			saveSettings: (data) ->
+				data._csrf = window.csrfToken
+				ide.$http.post "/user/settings", data
+
+			saveProjectSettings: (data) ->
+				data._csrf = window.csrfToken
+				ide.$http.post "/project/#{ide.project_id}/settings", data
+		}
+	]

+ 11 - 0
services/web/public/coffee/ide/share/controllers/ShareController.coffee

@@ -0,0 +1,11 @@
+define [
+	"base"
+], (App) ->
+	App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
+		$scope.openShareProjectModal = () ->
+			$modal.open(
+				templateUrl: "shareProjectModalTemplate"
+				controller:  "ShareProjectModalController"
+				scope: $scope
+			)
+	]

+ 104 - 0
services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee

@@ -0,0 +1,104 @@
+define [
+	"base"
+], (App) ->
+	App.controller "ShareProjectModalController", ["$scope", "$modalInstance", "$timeout", "projectMembers", "$modal", ($scope, $modalInstance, $timeout, projectMembers, $modal) ->
+		$scope.inputs = {
+			privileges: "readAndWrite"
+			email: ""
+		}
+		$scope.state = {
+			error: null
+			inflight: false
+			startedFreeTrial: false
+		}
+
+		$modalInstance.opened.then () ->
+			$timeout () ->
+				$scope.$broadcast "open"
+			, 200
+
+		INFINITE_COLLABORATORS = -1
+		$scope.$watch "project.members.length", (noOfMembers) ->
+			allowedNoOfMembers = $scope.project.features.collaborators
+			$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
+
+		$scope.addMember = () ->
+			console.log "EMAIL", $scope.inputs.email
+			return if !$scope.inputs.email? or $scope.inputs.email == ""
+			$scope.state.error = null
+			$scope.state.inflight = true
+			projectMembers
+				.addMember($scope.inputs.email, $scope.inputs.privileges)
+				.then (user) ->
+					$scope.state.inflight = false
+					$scope.inputs.email = ""
+					console.log "GOT USER", user
+					$scope.project.members.push user
+				.catch () ->
+					$scope.state.inflight = false
+					$scope.state.error = "Sorry, something went wrong :("
+
+
+		$scope.removeMember = (member) ->
+			$scope.state.error = null
+			$scope.state.inflight = true
+			projectMembers
+				.removeMember(member)
+				.then () ->
+					$scope.state.inflight = false
+					index = $scope.project.members.indexOf(member)
+					return if index == -1
+					$scope.project.members.splice(index, 1)
+				.catch () ->
+					$scope.state.inflight = false
+					$scope.state.error = "Sorry, something went wrong :("
+
+		$scope.startFreeTrial = () ->
+			ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', "projectMembers")
+			window.open("/user/subscription/plans")
+			$scope.state.startedFreeTrial = true
+
+		$scope.openMakePublicModal = () ->
+			$modal.open {
+				templateUrl: "makePublicModalTemplate"
+				controller:  "MakePublicModalController"
+				scope: $scope
+			}
+
+		$scope.openMakePrivateModal = () ->
+			$modal.open {
+				templateUrl: "makePrivateModalTemplate"
+				controller:  "MakePrivateModalController"
+				scope: $scope
+			}
+
+		$scope.done = () ->
+			$modalInstance.close()
+
+		$scope.cancel = () ->
+			$modalInstance.dismiss()
+	]
+
+	App.controller "MakePublicModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
+		$scope.inputs = {
+			privileges: "readAndWrite"
+		}
+
+		$scope.makePublic = () ->
+			$scope.project.publicAccesLevel = $scope.inputs.privileges
+			settings.saveProjectSettings({publicAccessLevel: $scope.inputs.privileges})
+			$modalInstance.close()
+
+		$scope.cancel = () ->
+			$modalInstance.dismiss()
+	]
+
+	App.controller "MakePrivateModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
+		$scope.makePrivate = () ->
+			$scope.project.publicAccesLevel = "private"
+			settings.saveProjectSettings({publicAccessLevel: "private"})
+			$modalInstance.close()
+
+		$scope.cancel = () ->
+			$modalInstance.dismiss()
+	]

+ 5 - 0
services/web/public/coffee/ide/share/index.coffee

@@ -0,0 +1,5 @@
+define [
+	"ide/share/controllers/ShareController"
+	"ide/share/controllers/ShareProjectModalController"
+	"ide/share/services/projectMembers"
+], () ->

+ 30 - 0
services/web/public/coffee/ide/share/services/projectMembers.coffee

@@ -0,0 +1,30 @@
+define [
+	"base"
+], (App) ->
+	App.factory "projectMembers", ["ide", "$q", (ide, $q) ->
+		return {
+			removeMember: (member) ->
+				deferred = $q.defer()
+
+				ide.socket.emit "removeUserFromProject", member._id, (error) =>
+					if error?
+						return deferred.reject(error)
+					deferred.resolve()
+
+				return deferred.promise
+
+			addMember: (email, privileges) ->
+				deferred = $q.defer()
+
+				ide.socket.emit "addUserToProject", email, privileges, (error, user) =>
+					if error?
+						return deferred.reject(error)
+
+					if !user
+						deferred.reject()
+					else
+						deferred.resolve(user)
+
+				return deferred.promise
+		}
+	]

+ 256 - 0
services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee

@@ -0,0 +1,256 @@
+define [
+	"ide/track-changes/controllers/TrackChangesListController"
+	"ide/track-changes/controllers/TrackChangesDiffController"
+	"ide/track-changes/directives/infiniteScroll"
+], () ->
+	class TrackChangesManager
+		constructor: (@ide, @$scope) ->
+			@reset()
+
+			@$scope.toggleTrackChanges = () =>
+				if @$scope.ui.view == "track-changes"
+					@hide()
+				else
+					@show()
+
+			@$scope.$watch "trackChanges.selection.updates", (updates) =>
+				if updates? and updates.length > 0
+					@_selectDocFromUpdates()
+					@reloadDiff()
+
+			@$scope.$on "entity:selected", (event, entity) =>
+				if (@$scope.ui.view == "track-changes") and (entity.type == "doc")
+					@$scope.trackChanges.selection.doc = entity
+					@reloadDiff()
+
+		show: () ->
+			@$scope.ui.view = "track-changes"
+			@reset()
+
+		hide: () ->
+			@$scope.ui.view = "editor"
+			# Make sure we run the 'open' logic for whatever is currently selected
+			@$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity()
+
+		reset: () ->
+			@$scope.trackChanges = {
+				updates: []
+				nextBeforeTimestamp: null
+				atEnd: false
+				selection: {
+					updates: []
+					doc: null
+					range: {
+						fromV: null
+						toV: null
+						start_ts: null
+						end_ts: null
+					}
+				}
+				diff: null
+			}
+
+		autoSelectRecentUpdates: () ->
+			console.log "AUTO SELECTING UPDATES", @$scope.trackChanges.updates.length
+			return if @$scope.trackChanges.updates.length == 0
+
+			@$scope.trackChanges.updates[0].selectedTo = true
+
+			indexOfLastUpdateNotByMe = 0
+			for update, i in @$scope.trackChanges.updates
+				if @_updateContainsUserId(update, @$scope.user.id)
+					break
+				indexOfLastUpdateNotByMe = i
+
+			@$scope.trackChanges.updates[indexOfLastUpdateNotByMe].selectedFrom = true
+
+		BATCH_SIZE: 4
+		fetchNextBatchOfUpdates: () ->
+			url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
+			if @$scope.trackChanges.nextBeforeTimestamp?
+				url += "&before=#{@$scope.trackChanges.nextBeforeTimestamp}"
+			@$scope.trackChanges.loading = true
+			@ide.$http
+				.get(url)
+				.success (data) =>
+					@_loadUpdates(data.updates)
+					@$scope.trackChanges.nextBeforeTimestamp = data.nextBeforeTimestamp
+					if !data.nextBeforeTimestamp?
+						@$scope.trackChanges.atEnd = true
+					@$scope.trackChanges.loading = false
+
+		reloadDiff: () ->
+			diff = @$scope.trackChanges.diff
+			{updates, doc} = @$scope.trackChanges.selection
+			{fromV, toV}   = @_calculateRangeFromSelection()
+
+			console.log "Checking if diff has changed", doc?.id, fromV, toV, updates
+
+			return if !doc?
+
+			return if diff? and
+				diff.doc   == doc   and
+				diff.fromV == fromV and
+				diff.toV   == toV
+
+			console.log "Loading diff", fromV, toV, doc?.id
+
+			@$scope.trackChanges.diff = diff = {
+				fromV:   fromV
+				toV:     toV
+				doc:     doc
+				error:   false
+			}
+
+			if !doc.deleted
+				diff.loading = true
+				url = "/project/#{@$scope.project_id}/doc/#{diff.doc.id}/diff"
+				if diff.fromV? and diff.toV?
+					url += "?from=#{diff.fromV}&to=#{diff.toV}"
+
+				@ide.$http
+					.get(url)
+					.success (data) =>
+						diff.loading = false
+						{text, highlights} = @_parseDiff(data)
+						diff.text = text
+						diff.highlights = highlights
+					.error () ->
+						diff.loading = false
+						diff.error = true
+			else
+				diff.deleted = true
+				console.log "DOC IS DELETED - NO DIFF FOR YOU!"
+
+		restoreDeletedDoc: (doc) ->
+			@ide.$http.post "/project/#{@$scope.project_id}/doc/#{doc.id}/restore", {
+				name: doc.name
+				_csrf: window.csrfToken
+			}
+
+		_parseDiff: (diff) ->
+			row    = 0
+			column = 0
+			highlights = []
+			text   = ""
+			for entry, i in diff.diff or []
+				content = entry.u or entry.i or entry.d
+				content ||= ""
+				text += content
+				lines   = content.split("\n")
+				startRow    = row
+				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
+
+				range = {
+					start:
+						row: startRow
+						column: startColumn
+					end:
+						row: endRow
+						column: endColumn
+				}
+
+				if entry.i? or entry.d?
+					if entry.meta.user?
+						name = "#{entry.meta.user.first_name} #{entry.meta.user.last_name}"
+					else
+						name = "Anonymous"
+					if entry.meta.user?.id == @$scope.user.id
+						name = "you"
+					date = moment(entry.meta.end_ts).format("Do MMM YYYY, h:mm a")
+					if entry.i?
+						highlights.push {
+							label: "Added by #{name} on #{date}"
+							highlight: range
+							hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
+						}
+					else if entry.d?
+						highlights.push {
+							label: "Deleted by #{name} on #{date}"
+							strikeThrough: range
+							hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
+						}
+
+			return {text, highlights}
+
+		_loadUpdates: (updates = []) ->
+			previousUpdate = @$scope.trackChanges.updates[@$scope.trackChanges.updates.length - 1]
+
+			for update in updates
+				for doc_id, doc of update.docs or {}
+					doc.entity = @ide.fileTreeManager.findEntityById(doc_id, includeDeleted: true)
+
+				for user in update.meta.users or []
+					user.hue = @ide.onlineUsersManager.getHueForUserId(user.id)
+
+				if !previousUpdate? or !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
+
+				previousUpdate = update
+
+			firstLoad = @$scope.trackChanges.updates.length == 0
+
+			@$scope.trackChanges.updates =
+				@$scope.trackChanges.updates.concat(updates)
+
+			@autoSelectRecentUpdates() if firstLoad
+
+		_calculateRangeFromSelection: () ->
+			fromV = toV = start_ts = end_ts = null
+
+			selected_doc_id = @$scope.trackChanges.selection.doc?.id
+
+			for update in @$scope.trackChanges.selection.updates or []
+				for doc_id, doc of update.docs
+					if doc_id == selected_doc_id
+						if fromV? and toV?
+							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.fromV
+							toV = doc.toV
+							start_ts = update.meta.start_ts
+							end_ts = update.meta.end_ts
+						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: () ->
+			affected_docs = {}
+			for update in @$scope.trackChanges.selection.updates
+				for doc_id, doc of update.docs
+					affected_docs[doc_id] = doc.entity
+
+			selected_doc = @$scope.trackChanges.selection.doc
+			if selected_doc? and affected_docs[selected_doc.id]?
+				console.log "An affected doc is already open, bravo!"
+			else
+				console.log "selected doc is not open, selecting first one"
+				for doc_id, doc of affected_docs
+					selected_doc = doc
+					break
+
+			@$scope.trackChanges.selection.doc = selected_doc
+			@ide.fileTreeManager.selectEntity(selected_doc)
+
+		_updateContainsUserId: (update, user_id) ->
+			for user in update.meta.users
+				return true if user.id == user_id
+			return false

+ 9 - 0
services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee

@@ -0,0 +1,9 @@
+define [
+	"base"
+], (App) ->
+	App.controller "TrackChangesDiffController", ["$scope", "ide", ($scope, ide) ->
+		$scope.restoreDeletedDoc = () ->
+			ide.trackChangesManager.restoreDeletedDoc(
+				$scope.trackChanges.diff.doc
+			)
+	]

+ 109 - 0
services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee

@@ -0,0 +1,109 @@
+define [
+	"base"
+], (App) ->
+	App.controller "TrackChangesListController", ["$scope", "ide", ($scope, ide) ->
+		$scope.hoveringOverListSelectors = false
+
+		$scope.loadMore = () =>
+			ide.trackChangesManager.fetchNextBatchOfUpdates()
+
+		$scope.recalculateSelectedUpdates = () ->
+			console.log "RECALCULATING UPDATES"
+			beforeSelection = true
+			afterSelection = false
+			$scope.trackChanges.selection.updates = []
+			for update in $scope.trackChanges.updates
+				if update.selectedTo
+					inSelection = true
+					beforeSelection = false
+
+				update.beforeSelection = beforeSelection
+				update.inSelection = inSelection
+				update.afterSelection = afterSelection
+
+				if inSelection
+					$scope.trackChanges.selection.updates.push update
+
+				if update.selectedFrom
+					inSelection = false
+					afterSelection = true
+
+		$scope.recalculateHoveredUpdates = () ->
+			hoverSelectedFrom = false
+			hoverSelectedTo = false
+			for update in $scope.trackChanges.updates
+				# Figure out whether the to or from selector is hovered over
+				if update.hoverSelectedFrom
+					hoverSelectedFrom = true
+				if update.hoverSelectedTo
+					hoverSelectedTo = true
+
+			if hoverSelectedFrom
+				# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
+				inHoverSelection = false
+				for update in $scope.trackChanges.updates
+					if update.selectedTo
+						update.hoverSelectedTo = true
+						inHoverSelection = true
+					update.inHoverSelection = inHoverSelection
+					if update.hoverSelectedFrom
+						inHoverSelection = false
+			if hoverSelectedTo
+				# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
+				inHoverSelection = false
+				for update in $scope.trackChanges.updates
+					if update.hoverSelectedTo
+						inHoverSelection = true
+					update.inHoverSelection = inHoverSelection
+					if update.selectedFrom
+						update.hoverSelectedFrom = true
+						inHoverSelection = false
+
+		$scope.resetHoverState = () ->
+			for update in $scope.trackChanges.updates
+				delete update.hoverSelectedFrom
+				delete update.hoverSelectedTo
+				delete update.inHoverSelection
+
+		$scope.$watch "trackChanges.updates.length", () ->
+			$scope.recalculateSelectedUpdates()
+	]
+
+	App.controller "TrackChangesListItemController", ["$scope", ($scope) ->
+		$scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) ->
+			if selectedFrom
+				console.log "SELECTED FROM CHANGED", $scope.update, selectedFrom, oldSelectedFrom
+				for update in $scope.trackChanges.updates
+					update.selectedFrom = false unless update == $scope.update
+				$scope.recalculateSelectedUpdates()		
+
+		$scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) ->
+			if selectedTo
+				console.log "SELECTED TO CHANGED", $scope.update, selectedTo, oldSelectedTo
+				for update in $scope.trackChanges.updates
+					update.selectedTo = false unless update == $scope.update
+				$scope.recalculateSelectedUpdates()
+
+		$scope.select = () ->
+			$scope.update.selectedTo = true
+			$scope.update.selectedFrom = true
+
+		$scope.mouseOverSelectedFrom = () ->
+			$scope.trackChanges.hoveringOverListSelectors = true
+			$scope.update.hoverSelectedFrom = true
+			$scope.recalculateHoveredUpdates()
+
+		$scope.mouseOutSelectedFrom = () ->
+			$scope.trackChanges.hoveringOverListSelectors = false
+			$scope.resetHoverState()
+
+		$scope.mouseOverSelectedTo = () ->
+			$scope.trackChanges.hoveringOverListSelectors = true
+			$scope.update.hoverSelectedTo = true
+			$scope.recalculateHoveredUpdates()
+
+		$scope.mouseOutSelectedTo = () ->
+			$scope.trackChanges.hoveringOverListSelectors = false
+			$scope.resetHoverState()
+
+	]

+ 45 - 0
services/web/public/coffee/ide/track-changes/directives/infiniteScroll.coffee

@@ -0,0 +1,45 @@
+define [
+	"base"
+], (App) ->
+	App.directive "infiniteScroll", () ->
+		return {
+			link: (scope, element, attrs, ctrl) ->
+				innerElement = element.find(".infinite-scroll-inner")
+				element.css 'overflow-y': 'auto'
+
+				atEndOfListView = () ->
+					element.scrollTop() + element.height() >= innerElement.height() - 30
+
+				listShorterThanContainer = () ->
+					element.innerHeight() > @$(".change-list").outerHeight()
+
+				loadUntilFull = () ->
+					if (listShorterThanContainer() or atEndOfListView()) and not scope.$eval(attrs.infiniteScrollDisabled)
+						console.log "Loading more"
+						promise = scope.$eval(attrs.infiniteScroll)
+						console.log promise
+						promise.then () ->
+							loadUntilFull()
+						# @collection.fetchNextBatch
+						# 	error: (error) =>
+						# 		@hideLoading()
+						# 		@showEmptyMessageIfCollectionEmpty()
+						# 		callback(error)
+						# 	success: (collection, response) =>
+						# 		@hideLoading()
+						# 		if @collection.isAtEnd()
+						# 			@atEndOfCollection = true
+						# 			@showEmptyMessageIfCollectionEmpty()
+						# 			callback()
+						# 		else
+						# 			@loadUntilFull(callback)
+
+				element.on "scroll", (event) ->
+					loadUntilFull()
+
+				scope.$watch attrs.infiniteScrollInitialize, (value) ->
+					console.log "INITIALIZE", value
+					if value
+						loadUntilFull()
+
+		}

+ 9 - 0
services/web/public/coffee/libs.coffee

@@ -0,0 +1,9 @@
+define [
+	"moment"
+	"libs/angular-autocomplete/angular-autocomplete"
+	"libs/ui-bootstrap"
+	"libs/ng-context-menu-0.1.4"
+	"libs/underscore-1.3.3"
+	"libs/jquery.storage"
+	"libs/fineuploader"
+], () ->

+ 16 - 0
services/web/public/coffee/main.coffee

@@ -0,0 +1,16 @@
+define [
+	"main/project-list"
+	"main/user-details"
+	"main/account-settings"
+	"main/plans"
+	"main/group-members"
+	"directives/asyncForm"
+	"directives/stopPropagation"
+	"directives/focus"
+	"directives/equals"
+	"directives/fineUpload"
+	"directives/onEnter"
+	"directives/selectAll"
+	"filters/formatDate"
+], () ->
+	angular.bootstrap(document.body, ["SharelatexApp"])

+ 54 - 0
services/web/public/coffee/main/account-settings.coffee

@@ -0,0 +1,54 @@
+define [
+	"base"
+], (App) ->
+	App.controller "AccountSettingsController", ["$scope", "$http", "$modal", ($scope, $http, $modal) ->
+		$scope.subscribed = true
+
+		$scope.unsubscribe = () ->
+			$scope.unsubscribing = true
+			$http({
+					method: "DELETE"
+					url: "/user/newsletter/unsubscribe"
+					headers:
+						"X-CSRF-Token": window.csrfToken
+				})
+				.success () ->
+					$scope.unsubscribing = false
+					$scope.subscribed = false
+				.error () ->
+					$scope.unsubscribing = true
+
+		$scope.deleteAccount = () ->
+			modalInstance = $modal.open(
+				templateUrl: "deleteAccountModalTemplate"
+				controller: "DeleteAccountModalController"
+			)
+	]
+
+	App.controller "DeleteAccountModalController", [
+		"$scope", "$modalInstance", "$timeout", "$http",
+		($scope,   $modalInstance,   $timeout,   $http) ->
+			$scope.state =
+				inflight: false
+
+			$modalInstance.opened.then () ->
+				$timeout () ->
+					$scope.$broadcast "open"
+				, 700
+
+			$scope.delete = () ->
+				$scope.state.inflight = true
+
+				$http({
+						method: "DELETE"
+						url: "/user"
+						headers:
+							"X-CSRF-Token": window.csrfToken
+					})
+					.success () ->
+						$modalInstance.close()
+						window.location = "/"
+
+			$scope.cancel = () ->
+				$modalInstance.dismiss('cancel')
+	]

部分文件因文件數量過多而無法顯示