James Allen 12 лет назад
Родитель
Сommit
a1b715d1e9

+ 4 - 2
services/web/app/views/project/editor.jade

@@ -42,6 +42,7 @@ block content
 			include ./editor/file-tree
 
 			.ui-layout-center
+				#editor(ace-editor, theme="'cobalt'", show-print-margin="false", sharejs-doc="editor.sharejs_doc")
 
 	//- #loadingScreen
 	//- 	h3 Loading...
@@ -80,9 +81,10 @@ block content
 		window.csrfToken = "!{csrfToken}";
 		window.requirejs = {
 			"paths" : {
-				"underscore": "libs/underscore-1.3.3",
+				"underscore": "../libs/underscore-1.3.3",
 				"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML",
-				"moment": "libs/moment-2.4.0"
+				"moment": "libs/moment-2.4.0",
+				"ace": "#{jsPath}ace"
 			},
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}",
 			"waitSeconds": 0,

+ 3 - 0
services/web/public/coffee/app/ide.coffee

@@ -2,6 +2,7 @@ define [
 	"base"
 	"ide/file-tree/FileTreeManager"
 	"ide/connection/ConnectionManager"
+	"ide/editor/EditorManager"
 	"ide/directives/layout"
 	"ide/services/ide"
 	"directives/focus"
@@ -11,6 +12,7 @@ define [
 	App
 	FileTreeManager
 	ConnectionManager
+	EditorManager
 ) ->
 	App.controller "IdeController", ["$scope", "$timeout", "ide", ($scope, $timeout, ide) ->
 		$scope.state = {
@@ -25,6 +27,7 @@ define [
 
 		ide.connectionManager = new ConnectionManager(ide, $scope)
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
+		ide.editorManager = new EditorManager(ide, $scope)
 	]
 
 	angular.bootstrap(document.body, ["SharelatexApp"])

+ 9 - 1
services/web/public/coffee/app/ide/connection/ConnectionManager.coffee

@@ -24,4 +24,12 @@ define [], () ->
 
 							@$scope.$emit "project:joined"
 
-				setTimeout(joinProject, 100)
+				setTimeout(joinProject, 100)
+
+		reconnectImmediately: () ->
+			console.log "RECONNECT IMMEDIATELY STUB"
+			@disconnect()
+			#@tryReconnect()
+
+		disconnect: () ->
+			@socket.disconnect()

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

@@ -0,0 +1,250 @@
+define [
+	"utils/EventEmitter"
+	"ide/editor/ShareJsDoc"
+	"underscore"
+], (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 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

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

@@ -0,0 +1,55 @@
+define [
+	"ide/editor/Document"
+	"ide/editor/directives/aceEditor"
+], (Document) ->
+	class EditorManager
+		constructor: (@ide, @$scope) ->
+			@$scope.editor = {
+				sharejs_doc: null
+			}
+
+		openDoc: (doc, options = {}) ->
+			# TODO: Don't open if already open
+			@_openNewDocument doc, (error, sharejs_doc) =>
+				if error?
+					@ide.showGenericServerErrorMessage()
+					return
+
+				@$scope.$apply () =>
+					@$scope.editor.sharejs_doc = sharejs_doc
+
+		_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(new_sharejs_doc)
+				callback null, new_sharejs_doc
+
+		_bindToDocumentEvents: (document) ->
+
+			document.on "error", (error) =>
+				@openDoc(document.doc_id, forceReopen: true)
+
+				Modal.createModal
+					title: "Out of sync"
+					message: "Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently."
+					buttons:[
+						text: "Ok"
+					]
+
+			document.on "externalUpdate", () =>
+				Modal.createModal
+					title: "Document Updated Externally"
+					message: "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."
+					buttons:[
+						text: "Ok"
+					]
+
+		_unbindFromDocumentEvents: (document) ->
+			document.off()

+ 123 - 0
services/web/public/coffee/app/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)

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

@@ -0,0 +1,97 @@
+define [
+	"base"
+	"ide/editor/undo/UndoManager"
+	"ace/ace"
+	"ace/keyboard/vim"
+	"ace/keyboard/emacs"
+	"ace/mode/latex"
+	"ace/edit_session"
+], (App, UndoManager, Ace) ->
+	LatexMode = require("ace/mode/latex").Mode
+	EditSession = require('ace/edit_session').EditSession
+
+	App.directive "aceEditor", ["$timeout", ($timeout) ->
+		return  {
+			scope: {
+				theme: "="
+				showPrintMargin: "="
+				keybindings: "="
+				sharejsDoc: "="
+			}
+			link: (scope, element, attrs) ->
+				editor = Ace.edit(element.find(".ace-editor-body")[0])
+				scope.undo =
+					show_remote_warning: true
+
+				# 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"
+
+				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 = ace: null, vim: Vim, emacs: Emacs
+					editor.setKeyboardHandler(keybindings[value])
+
+				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
+					console.log "attaching doc to ace", sharejs_doc, old_sharejs_doc
+					if old_sharejs_doc?
+						old_sharejs_doc.detachFromAce()
+						old_sharejs_doc.off "remoteop.recordForUndo"
+
+					if sharejs_doc?
+						lines = sharejs_doc.getSnapshot().split("\n") 
+						editor.setSession(new EditSession(lines))
+						session = editor.getSession()
+						session.setUseWrapMode(true)
+						session.setMode(new LatexMode())
+
+						undoManager = new UndoManager({
+							showUndoConflictWarning: () ->
+								scope.$apply () ->
+									scope.undo.show_remote_warning = true
+
+								$timeout () -> 
+									scope.undo.show_remote_warning = false
+								, 4000
+
+						})
+						session.setUndoManager(undoManager)
+
+						sharejs_doc.on "remoteop.recordForUndo", () =>
+							console.log "Remote OP!"
+							undoManager.nextUpdateIsRemote = true
+
+						sharejs_doc.attachToAce(editor)
+
+			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>
+			"""
+		}
+	]

+ 358 - 0
services/web/public/coffee/app/ide/editor/undo/UndoManager.coffee

@@ -0,0 +1,358 @@
+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: (@manager) ->
+			@reset()
+			@nextUpdateIsRemote = false
+
+		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
+				@manager.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/app/ide/file-tree/controllers/FileTreeEntityController.coffee

@@ -7,6 +7,9 @@ define [
 				entity.selected = false
 			$scope.entity.selected = true
 
+			if ($scope.entity.type == "doc")
+				ide.editorManager.openDoc($scope.entity)
+
 		$scope.inputs =
 			name: $scope.entity.name
 

+ 9 - 0
services/web/public/coffee/app/ide/services/ide.coffee

@@ -7,5 +7,14 @@ define [
 		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"
+
 		return ide
 	]

+ 1 - 2
services/web/public/coffee/app/main.coffee

@@ -4,8 +4,7 @@ define [
 	"main/account-settings"
 	"directives/asyncForm"
 	"directives/stopPropagation"
-	"directives/focusInput"
-	"directives/focusOn"
+	"directives/focus"
 	"directives/equals"
 	"directives/fineUpload"
 	"directives/onEnter"

+ 33 - 0
services/web/public/coffee/app/utils/EventEmitter.coffee

@@ -0,0 +1,33 @@
+define [], () ->
+	class EventEmitter
+		on: (event, callback) ->
+			@events ||= {}
+			[event, namespace] = event.split(".")
+			@events[event] ||= []
+			@events[event].push {
+				callback: callback
+				namespace: namespace
+			}
+
+		off: (event) ->
+			@events ||= {}
+			if event?
+				[event, namespace] = event.split(".")
+				if !namespace?
+					# Clear all listeners for event
+					delete @events[event]
+				else
+					# Clear only namespaced listeners
+					remaining_events = []
+					for callback in @events[event] or []
+						if callback.namespace != namespace
+							remaining_events.push callback
+					@events[event] = remaining_events
+			else
+				# Remove all listeners
+				@events = {}
+
+		trigger: (event, args...) ->
+			@events ||= {}
+			for callback in @events[event] or []
+				callback.callback(args...)

+ 7 - 5
services/web/public/coffee/editor/sharejs/client/ace.coffee

@@ -62,11 +62,13 @@ window.sharejs.extendDoc 'attach_ace', (editor, keepEditorContents) ->
           # 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()
+  # MODIFIED by James: We will set the doc contents ourselves to
+  # avoid an extra entry in the undo stack.
+  # if keepEditorContents
+  #   doc.del 0, doc.getText().length
+  #   doc.insert 0, editorDoc.getValue()
+  # else
+  #   editorDoc.setValue doc.getText()
 
   check()
 

+ 30 - 1
services/web/public/stylesheets/app/editor.less

@@ -67,7 +67,6 @@
 
 #file-tree {
 	background-color: #fafafa;
-	border-right: 1px solid @toolbar-border-color;
 
 	ul.file-tree-list {
 		font-size: 0.8rem;
@@ -153,9 +152,39 @@
 	}
 }
 
+#editor {
+	position: absolute;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+}
+
+// The internal components of the aceEditor directive
+.ace-editor-wrapper {
+	position: absolute;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	.undo-conflict-warning {
+		position: absolute;
+		top: 0;
+		right: 0;
+		left: 0;
+		z-index: 10;
+	}
+	.ace-editor-body {
+		width: 100%;
+		height: 100%;
+	}
+}
+
 .ui-layout-resizer {
 	width: 6px;
 	background-color: #f4f4f4;
+	border-left: 1px solid @toolbar-border-color;
+	border-right: 1px solid @toolbar-border-color;
 	.ui-layout-toggler {
 		color: #999;
 		font-family: FontAwesome;