Просмотр исходного кода

Merge branch 'master' into pr-style-v2-chatpane

Paulo Reis 8 лет назад
Родитель
Сommit
0a0c6c6c0c
62 измененных файлов с 1277 добавлено и 484 удалено
  1. 6 0
      services/web/.gitignore
  2. 4 4
      services/web/Gruntfile.coffee
  3. 7 10
      services/web/Jenkinsfile
  4. 14 7
      services/web/Makefile
  5. 14 4
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  6. 4 4
      services/web/app/coffee/Features/Editor/EditorController.coffee
  7. 2 1
      services/web/app/coffee/Features/Editor/EditorHttpController.coffee
  8. 3 25
      services/web/app/coffee/Features/History/HistoryController.coffee
  9. 26 0
      services/web/app/coffee/Features/History/HistoryManager.coffee
  10. 2 1
      services/web/app/coffee/Features/Project/ProjectController.coffee
  11. 2 2
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  12. 1 1
      services/web/app/coffee/Features/Project/ProjectDuplicator.coffee
  13. 72 51
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  14. 1 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee
  15. 2 2
      services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee
  16. 41 40
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  17. 1 0
      services/web/app/coffee/models/Project.coffee
  18. 5 0
      services/web/app/coffee/router.coffee
  19. 13 8
      services/web/app/views/layout.pug
  20. 11 11
      services/web/app/views/project/editor.pug
  21. 13 4
      services/web/app/views/project/editor/history.pug
  22. 1 0
      services/web/app/views/project/list/item.pug
  23. 0 0
      services/web/bin/compile_backend
  24. 5 0
      services/web/bin/compile_frontend
  25. 5 0
      services/web/bin/compile_frontend_tests
  26. 5 0
      services/web/bin/frontend_test
  27. 2 1
      services/web/config/settings.defaults.coffee
  28. 3 5
      services/web/docker-shared.template.yml
  29. 7 3
      services/web/package.json
  30. 1 1
      services/web/public/coffee/base.coffee
  31. 4 1
      services/web/public/coffee/directives/selectAll.coffee
  32. 6 1
      services/web/public/coffee/ide.coffee
  33. 1 1
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  34. 3 0
      services/web/public/coffee/ide/history/HistoryManager.coffee
  35. 280 0
      services/web/public/coffee/ide/history/HistoryV2Manager.coffee
  36. 3 9
      services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
  37. 0 5
      services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee
  38. 0 0
      services/web/public/coffee/libraries.coffee
  39. 7 4
      services/web/public/coffee/main/account-upgrade.coffee
  40. 3 0
      services/web/public/coffee/main/project-list/project-list.coffee
  41. 1 1
      services/web/public/coffee/utils/underscore.coffee
  42. 12 2
      services/web/public/stylesheets/app/editor/history.less
  43. 32 19
      services/web/public/stylesheets/app/editor/pdf.less
  44. 2 2
      services/web/public/stylesheets/app/editor/toolbar.less
  45. 1 1
      services/web/public/stylesheets/components/alerts.less
  46. 0 1
      services/web/public/stylesheets/components/card.less
  47. 23 13
      services/web/public/stylesheets/core/_common-variables.less
  48. 50 14
      services/web/public/stylesheets/core/ol-variables.less
  49. 140 1
      services/web/test/acceptance/coffee/ProjectStructureTests.coffee
  50. 3 0
      services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee
  51. 10 0
      services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee
  52. 13 5
      services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
  53. 24 29
      services/web/test/unit/coffee/Editor/EditorControllerTests.coffee
  54. 2 2
      services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee
  55. 48 151
      services/web/test/unit/coffee/History/HistoryControllerTests.coffee
  56. 86 0
      services/web/test/unit/coffee/History/HistoryManagerTests.coffee
  57. 4 4
      services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
  58. 6 6
      services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee
  59. 98 20
      services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee
  60. 5 3
      services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee
  61. 3 3
      services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee
  62. 134 0
      services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee

+ 6 - 0
services/web/.gitignore

@@ -39,6 +39,7 @@ data/*
 app.js
 app/js/*
 test/unit/js/*
+test/unit_frontend/js/*
 test/smoke/js/*
 test/acceptance/js/*
 cookies.txt
@@ -67,6 +68,11 @@ public/minjs/
 
 Gemfile.lock
 
+public/stylesheets/ol-style.*.css
+public/stylesheets/style.*.css
+public/js/libs/require*.js
+
+
 *.swp
 .DS_Store
 

+ 4 - 4
services/web/Gruntfile.coffee

@@ -205,12 +205,12 @@ module.exports = (grunt) ->
 					modules: [
 						{
 							name: "main",
-							exclude: ["libs"]
+							exclude: ["libraries"]
 						}, {
 							name: "ide",
-							exclude: ["libs", "pdfjs-dist/build/pdf"]
-						}, {
-							name: "libs"
+							exclude: ["pdfjs-dist/build/pdf", "libraries"]
+						},{
+							name: "libraries"
 						},{
 							name: "ace/mode-latex"
 						},{

+ 7 - 10
services/web/Jenkinsfile

@@ -60,7 +60,7 @@ pipeline {
         sh 'git config --global core.logallrefupdates false'
         sh 'mv app/views/external/robots.txt public/robots.txt'
         sh 'mv app/views/external/googlebdb0f8f7f4a17241.html public/googlebdb0f8f7f4a17241.html'
-        sh 'npm install'
+        sh 'npm --quiet install'
         sh 'npm rebuild'
         // It's too easy to end up shrinkwrapping to an outdated version of translations.
         // Ensure translations are always latest, regardless of shrinkwrap
@@ -71,16 +71,9 @@ pipeline {
       }
     }
     
-    stage('Unit Tests') {
+    stage('Test') {
       steps {
-        sh 'make clean install' // Removes js files, so do before compile
-        sh 'make test_unit MOCHA_ARGS="--reporter=tap"'
-      }
-    }
-    
-    stage('Acceptance Tests') {
-      steps {
-        sh 'make test_acceptance MOCHA_ARGS="--reporter=tap"'
+        sh 'make ci'
       }
     }
 
@@ -155,6 +148,10 @@ pipeline {
   }
   
   post {
+    always {
+      sh 'make ci_clean'
+    }
+
     failure {
       mail(from: "${EMAIL_ALERT_FROM}", 
            to: "${EMAIL_ALERT_TO}", 

+ 14 - 7
services/web/Makefile

@@ -16,9 +16,10 @@ add_dev: docker-shared.yml
 	$(NPM) install --save-dev ${P}
 
 install: docker-shared.yml
+	bin/generate_volumes_file
 	$(NPM) install
 
-clean:
+clean: ci_clean
 	rm -f app.js
 	rm -rf app/js
 	rm -rf test/unit/js
@@ -30,9 +31,8 @@ clean:
 		rm -rf $$dir/test/unit/js; \
 		rm -rf $$dir/test/acceptance/js; \
 	done
-	# Regenerate docker-shared.yml - not stictly a 'clean',
-	# but lets `make clean install` work nicely
-	bin/generate_volumes_file
+
+ci_clean:
 	# Deletes node_modules volume
 	docker-compose down --volumes
 
@@ -40,11 +40,14 @@ clean:
 docker-shared.yml:
 	bin/generate_volumes_file
 
-test: test_unit test_acceptance
+test: test_unit test_frontend test_acceptance
 
 test_unit: docker-shared.yml
 	docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:unit -- ${MOCHA_ARGS}
 
+test_frontend: docker-shared.yml
+	docker-compose ${DOCKER_COMPOSE_FLAGS} run --rm test_unit npm -q run test:frontend -- ${MOCHA_ARGS}
+
 test_acceptance: test_acceptance_app test_acceptance_modules
 
 test_acceptance_app: test_acceptance_app_start_service test_acceptance_app_run test_acceptance_app_stop_service
@@ -71,7 +74,11 @@ test_acceptance_modules: docker-shared.yml
 test_acceptance_module: docker-shared.yml
 	cd $(MODULE) && make test_acceptance
 
+ci:
+	MOCHA_ARGS="--reporter tap" \
+	$(MAKE) install test
+
 .PHONY:
-	all add install update test test_unit test_acceptance \
+	all add install update test test_unit test_frontend test_acceptance \
 	test_acceptance_start_service test_acceptance_stop_service \
-	test_acceptance_run
+	test_acceptance_run ci ci_clean

+ 14 - 4
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -205,10 +205,10 @@ module.exports = DocumentUpdaterHandler =
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
 	updateProjectStructure : (project_id, userId, changes, callback = (error) ->)->
-		return callback() if !settings.apis.project_history?.enabled
+		return callback() if !settings.apis.project_history?.sendProjectStructureOps
 
-		docUpdates = DocumentUpdaterHandler._getRenameUpdates('doc', changes.oldDocs, changes.newDocs)
-		fileUpdates = DocumentUpdaterHandler._getRenameUpdates('file', changes.oldFiles, changes.newFiles)
+		docUpdates = DocumentUpdaterHandler._getUpdates('doc', changes.oldDocs, changes.newDocs)
+		fileUpdates = DocumentUpdaterHandler._getUpdates('file', changes.oldFiles, changes.newFiles)
 
 		timer = new metrics.Timer("set-document")
 		url = "#{settings.apis.documentupdater.url}/project/#{project_id}"
@@ -230,7 +230,7 @@ module.exports = DocumentUpdaterHandler =
 				logger.error {project_id, url}, "doc updater returned a non-success status code: #{res.statusCode}"
 				callback new Error("doc updater returned a non-success status code: #{res.statusCode}")
 
-	_getRenameUpdates: (entityType, oldEntities, newEntities) ->
+	_getUpdates: (entityType, oldEntities, newEntities) ->
 		oldEntities ||= []
 		newEntities ||= []
 		updates = []
@@ -255,6 +255,16 @@ module.exports = DocumentUpdaterHandler =
 					pathname: oldEntity.path
 					newPathname: newEntity.path
 
+		for id, oldEntity of oldEntitiesHash
+			newEntity = newEntitiesHash[id]
+
+			if !newEntity?
+				# entity deleted
+				updates.push
+					id: id
+					pathname: oldEntity.path
+					newPathname: ''
+
 		updates
 
 PENDINGUPDATESKEY = "PendingUpdates"

+ 4 - 4
services/web/app/coffee/Features/Editor/EditorController.coffee

@@ -105,19 +105,19 @@ module.exports = EditorController =
 			async.series jobs, (err)->
 				callback err, newFolders, lastFolder
 
-	deleteEntity : (project_id, entity_id, entityType, source, callback)->
+	deleteEntity : (project_id, entity_id, entityType, source, userId, callback)->
 		LockManager.getLock project_id, (err)->
 			if err?
 				logger.err err:err, project_id:project_id, "could not get lock to deleteEntity"
 				return callback(err)
-			EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, (err)->
+			EditorController.deleteEntityWithoutLock project_id, entity_id, entityType, source, userId, (err)->
 				LockManager.releaseLock project_id, ()->
 					callback(err)
 
-	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, callback)->
+	deleteEntityWithoutLock: (project_id, entity_id, entityType, source, userId, callback)->
 		logger.log {project_id, entity_id, entityType, source}, "start delete process of entity"
 		Metrics.inc "editor.delete-entity"
-		ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, (err)->
+		ProjectEntityHandler.deleteEntity project_id, entity_id, entityType, userId, (err)->
 			if err?
 				logger.err err:err, project_id:project_id, entity_id:entity_id, entityType:entityType, "error deleting entity"
 				return callback(err)

+ 2 - 1
services/web/app/coffee/Features/Editor/EditorHttpController.coffee

@@ -147,6 +147,7 @@ module.exports = EditorHttpController =
 		project_id  = req.params.Project_id
 		entity_id   = req.params.entity_id
 		entity_type = req.params.entity_type
-		EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		EditorController.deleteEntity project_id, entity_id, entity_type, "editor", user_id, (error) ->
 			return next(error) if error?
 			res.sendStatus 204

+ 3 - 25
services/web/app/coffee/Features/History/HistoryController.coffee

@@ -5,35 +5,13 @@ AuthenticationController = require "../Authentication/AuthenticationController"
 ProjectDetailsHandler = require "../Project/ProjectDetailsHandler"
 
 module.exports = HistoryController =
-	initializeProject: (callback = (error, history_id) ->) ->
-		return callback() if !settings.apis.project_history?.enabled
-		request.post {
-			url: "#{settings.apis.project_history.url}/project"
-		}, (error, res, body)->
-			return callback(error) if error?
-
-			if res.statusCode >= 200 and res.statusCode < 300
-				try
-					project = JSON.parse(body)
-				catch error
-					return callback(error)
-
-				overleaf_id = project?.project?.id
-				if !overleaf_id
-					error = new Error("project-history did not provide an id", project)
-					return callback(error)
-
-				callback null, { overleaf_id }
-			else
-				error = new Error("project-history returned a non-success status code: #{res.statusCode}")
-				callback error
-
 	selectHistoryApi: (req, res, next = (error) ->) ->
 		project_id = req.params?.Project_id
 		# find out which type of history service this project uses
 		ProjectDetailsHandler.getDetails project_id, (err, project) ->
 			return next(err) if err?
-			if project?.overleaf?.history?.display
+			history = project.overleaf?.history
+			if history?.id? and history?.display
 				req.useProjectHistory = true
 			else
 				req.useProjectHistory = false
@@ -58,7 +36,7 @@ module.exports = HistoryController =
 	buildHistoryServiceUrl: (useProjectHistory) ->
 		# choose a history service, either document-level (trackchanges)
 		# or project-level (project_history)
-		if settings.apis.project_history?.enabled && useProjectHistory
+		if useProjectHistory
 			return settings.apis.project_history.url
 		else
 			return settings.apis.trackchanges.url

+ 26 - 0
services/web/app/coffee/Features/History/HistoryManager.coffee

@@ -0,0 +1,26 @@
+request = require "request"
+settings = require "settings-sharelatex"
+
+module.exports = HistoryManager =
+	initializeProject: (callback = (error, history_id) ->) ->
+		return callback() if !settings.apis.project_history?.initializeHistoryForNewProjects
+		request.post {
+			url: "#{settings.apis.project_history.url}/project"
+		}, (error, res, body)->
+			return callback(error) if error?
+
+			if res.statusCode >= 200 and res.statusCode < 300
+				try
+					project = JSON.parse(body)
+				catch error
+					return callback(error)
+
+				overleaf_id = project?.project?.id
+				if !overleaf_id
+					error = new Error("project-history did not provide an id", project)
+					return callback(error)
+
+				callback null, { overleaf_id }
+			else
+				error = new Error("project-history returned a non-success status code: #{res.statusCode}")
+				callback error

+ 2 - 1
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -216,7 +216,7 @@ module.exports = ProjectController =
 			project: (cb)->
 				ProjectGetter.getProject(
 					project_id,
-					{ name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1 },
+					{ name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1, 'overleaf.history.display': 1 },
 					cb
 				)
 			user: (cb)->
@@ -351,6 +351,7 @@ module.exports = ProjectController =
 					themes: THEME_LIST
 					maxDocLength: Settings.max_doc_length
 					showLinkSharingOnboarding: !!results.couldShowLinkSharingOnboarding
+					useV2History: !!project.overleaf?.history?.display
 				timer.done()
 
 	_buildProjectList: (allProjects, v1Projects = [])->

+ 2 - 2
services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee

@@ -7,7 +7,7 @@ Project = require('../../models/Project').Project
 Folder = require('../../models/Folder').Folder
 ProjectEntityHandler = require('./ProjectEntityHandler')
 ProjectDetailsHandler = require('./ProjectDetailsHandler')
-HistoryController = require('../History/HistoryController')
+HistoryManager = require('../History/HistoryManager')
 User = require('../../models/User').User
 fs = require('fs')
 Path = require "path"
@@ -27,7 +27,7 @@ module.exports = ProjectCreationHandler =
 			if projectHistoryId?
 				ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, callback
 			else
-				HistoryController.initializeProject (error, history) ->
+				HistoryManager.initializeProject (error, history) ->
 					return callback(error) if error?
 					ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, callback
 

+ 1 - 1
services/web/app/coffee/Features/Project/ProjectDuplicator.coffee

@@ -21,7 +21,7 @@ module.exports = ProjectDuplicator =
 				if !doc?._id?
 					return callback()
 				content = docContents[doc._id.toString()]
-				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)->
+				projectEntityHandler.addDoc newProject, desFolder._id, doc.name, content.lines, owner_id, (err, newDoc)->
 					if err?
 						logger.err err:err, "error copying doc"
 						return callback(err)

+ 72 - 51
services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee

@@ -149,14 +149,35 @@ module.exports = ProjectEntityHandler =
 		else
 			DocstoreManager.getDoc project_id, doc_id, options, callback
 
-	addDoc: (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
-		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) ->
+	addDoc: (project_or_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+		ProjectEntityHandler.addDocWithoutUpdatingHistory project_or_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path) ->
+			return callback(error) if error?
+			newDocs = [
+				doc: doc
+				path: path
+				docLines: docLines.join('\n')
+			]
+			project_id = project_or_id._id or project_or_id
+			DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) ->
+				return callback(error) if error?
+				callback null, doc, folder_id
+
+	addDocWithoutUpdatingHistory: (project_or_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+		# This method should never be called directly, except when importing a project
+		# from Overleaf. It skips sending updates to the project history, which will break
+		# the history unless you are making sure it is updated in some other way.
+		getProject = (cb) ->
+			if project_or_id._id? # project
+				return cb(null, project_or_id)
+			else # id
+				return ProjectGetter.getProjectWithOnlyFolders project_or_id, cb
+		getProject (error, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add doc"
 				return callback(err)
-			ProjectEntityHandler.addDocWithProject project, folder_id, docName, docLines, userId, callback
+			ProjectEntityHandler._addDocWithProject project, folder_id, docName, docLines, userId, callback
 
-	addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+	_addDocWithProject: (project, folder_id, docName, docLines, userId, callback = (error, doc, folder_id, path) ->)=>
 		project_id = project._id
 		logger.log project_id: project_id, folder_id: folder_id, doc_name: docName, "adding doc to project with project"
 		confirmFolder project, folder_id, (folder_id)=>
@@ -176,14 +197,7 @@ module.exports = ProjectEntityHandler =
 						rev:          0
 					}, (err) ->
 						return callback(err) if err?
-						newDocs = [
-							doc: doc
-							path: result?.path?.fileSystem
-							docLines: docLines.join('\n')
-						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newDocs}, (error) ->
-							return callback(error) if error?
-							callback null, doc, folder_id
+						callback(null, doc, folder_id, result?.path?.fileSystem)
 
 	restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
 		# getDoc will return the deleted doc's lines, but we don't actually remove
@@ -192,37 +206,37 @@ module.exports = ProjectEntityHandler =
 			return callback(error) if error?
 			ProjectEntityHandler.addDoc project_id, null, name, lines, callback
 
-	addFile: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)->
+	addFileWithoutUpdatingHistory: (project_id, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id, path, fileStoreUrl) ->)->
 		ProjectGetter.getProjectWithOnlyFolders project_id, (err, project) ->
 			if err?
 				logger.err project_id:project_id, err:err, "error getting project for add file"
 				return callback(err)
-			ProjectEntityHandler.addFileWithProject project, folder_id, fileName, path, userId, callback
-
-	addFileWithProject: (project, folder_id, fileName, path, userId, callback = (error, fileRef, folder_id) ->)->
-		project_id = project._id
-		logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file"
-		return callback(err) if err?
-		confirmFolder project, folder_id, (folder_id)->
-			fileRef = new File name : fileName
-			FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)->
-				if err?
-					logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3"
-					return callback(err)
-				ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+			logger.log project_id: project._id, folder_id: folder_id, file_name: fileName, path:path, "adding file"
+			return callback(err) if err?
+			confirmFolder project, folder_id, (folder_id)->
+				fileRef = new File name : fileName
+				FileStoreHandler.uploadFileFromDisk project._id, fileRef._id, path, (err, fileStoreUrl)->
 					if err?
-						logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project"
+						logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error uploading image to s3"
 						return callback(err)
-					tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) ->
-						return callback(err) if err?
-						newFiles = [
-							file: fileRef
-							path: result?.path?.fileSystem
-							url: fileStoreUrl
-						]
-						DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) ->
-							return callback(error) if error?
-							callback null, fileRef, folder_id
+					ProjectEntityHandler._putElement project, folder_id, fileRef, "file", (err, result)=>
+						if err?
+							logger.err err:err, project_id: project._id, folder_id: folder_id, file_name: fileName, fileRef:fileRef, "error adding file with project"
+							return callback(err)
+						tpdsUpdateSender.addFile {project_id:project._id, file_id:fileRef._id, path:result?.path?.fileSystem, project_name:project.name, rev:fileRef.rev}, (err) ->
+							return callback(err) if err?
+							callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl)
+
+	addFile:  (project_id, folder_id, fileName, fsPath, userId, callback = (error, fileRef, folder_id) ->)->
+		ProjectEntityHandler.addFileWithoutUpdatingHistory project_id, folder_id, fileName, fsPath, userId, (error, fileRef, folder_id, path, fileStoreUrl) ->
+			newFiles = [
+				file: fileRef
+				path: path
+				url: fileStoreUrl
+			]
+			DocumentUpdaterHandler.updateProjectStructure project_id, userId, {newFiles}, (error) ->
+				return callback(error) if error?
+				callback null, fileRef, folder_id
 
 	replaceFile: (project_id, file_id, fsPath, userId, callback)->
 		self = ProjectEntityHandler
@@ -412,7 +426,7 @@ module.exports = ProjectEntityHandler =
 				callback()
 
 
-	deleteEntity: (project_id, entity_id, entityType, callback = (error) ->)->
+	deleteEntity: (project_id, entity_id, entityType, userId, callback = (error) ->)->
 		self = @
 		logger.log entity_id:entity_id, entityType:entityType, project_id:project_id, "deleting project entity"
 		if !entityType?
@@ -423,7 +437,7 @@ module.exports = ProjectEntityHandler =
 			return callback(error) if error?
 			projectLocator.findElement {project: project, element_id: entity_id, type: entityType}, (error, entity, path)=>
 				return callback(error) if error?
-				ProjectEntityHandler._cleanUpEntity project, entity, entityType, (error) ->
+				ProjectEntityHandler._cleanUpEntity project, entity, entityType, path.fileSystem, userId, (error) ->
 					return callback(error) if error?
 					tpdsUpdateSender.deleteEntity project_id:project_id, path:path.fileSystem, project_name:project.name, (error) ->
 						return callback(error) if error?
@@ -456,17 +470,17 @@ module.exports = ProjectEntityHandler =
 							return callback(error) if error?
 							DocumentUpdaterHandler.updateProjectStructure project_id, userId, {oldDocs, newDocs, oldFiles, newFiles}, callback
 
-	_cleanUpEntity: (project, entity, entityType, callback = (error) ->) ->
+	_cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) ->
 		if(entityType.indexOf("file") != -1)
-			ProjectEntityHandler._cleanUpFile project, entity, callback
+			ProjectEntityHandler._cleanUpFile project, entity, path, userId, callback
 		else if (entityType.indexOf("doc") != -1)
-			ProjectEntityHandler._cleanUpDoc project, entity, callback
+			ProjectEntityHandler._cleanUpDoc project, entity, path, userId, callback
 		else if (entityType.indexOf("folder") != -1)
-			ProjectEntityHandler._cleanUpFolder project, entity, callback
+			ProjectEntityHandler._cleanUpFolder project, entity, path, userId, callback
 		else
 			callback()
 
-	_cleanUpDoc: (project, doc, callback = (error) ->) ->
+	_cleanUpDoc: (project, doc, path, userId, callback = (error) ->) ->
 		project_id = project._id.toString()
 		doc_id = doc._id.toString()
 		unsetRootDocIfRequired = (callback) =>
@@ -483,26 +497,33 @@ module.exports = ProjectEntityHandler =
 					return callback(error) if error?
 					DocstoreManager.deleteDoc project_id, doc_id, (error) ->
 						return callback(error) if error?
-						callback()
+						changes = oldDocs: [ {doc, path} ]
+						DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
 
-	_cleanUpFile: (project, file, callback = (error) ->) ->
+	_cleanUpFile: (project, file, path, userId, callback = (error) ->) ->
 		project_id = project._id.toString()
 		file_id = file._id.toString()
-		FileStoreHandler.deleteFile project_id, file_id, callback
+		FileStoreHandler.deleteFile project_id, file_id, (error) ->
+			return callback(error) if error?
+			changes = oldFiles: [ {file, path} ]
+			DocumentUpdaterHandler.updateProjectStructure project_id, userId, changes, callback
 
-	_cleanUpFolder: (project, folder, callback = (error) ->) ->
+	_cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) ->
 		jobs = []
 		for doc in folder.docs
 			do (doc) ->
-				jobs.push (callback) -> ProjectEntityHandler._cleanUpDoc project, doc, callback
+				docPath = path.join(folderPath, doc.name)
+				jobs.push (callback) -> ProjectEntityHandler._cleanUpDoc project, doc, docPath, userId, callback
 
 		for file in folder.fileRefs
 			do (file) ->
-				jobs.push (callback) -> ProjectEntityHandler._cleanUpFile project, file, callback
+				filePath = path.join(folderPath, file.name)
+				jobs.push (callback) -> ProjectEntityHandler._cleanUpFile project, file, filePath, userId, callback
 
 		for childFolder in folder.folders
 			do (childFolder) ->
-				jobs.push (callback) -> ProjectEntityHandler._cleanUpFolder project, childFolder, callback
+				folderPath = path.join(folderPath, childFolder.name)
+				jobs.push (callback) -> ProjectEntityHandler._cleanUpFolder project, childFolder, folderPath, userId, callback
 
 		async.series jobs, callback
 

+ 1 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateHandler.coffee

@@ -47,7 +47,7 @@ module.exports =
 				logger.log user_id:user_id, filePath:path, projectName:projectName, project_id:project._id, "project found for delete update, path is root so marking project as deleted"
 				return projectDeleter.markAsDeletedByExternalSource project._id, callback
 			else
-				updateMerger.deleteUpdate project._id, path, source, (err)->
+				updateMerger.deleteUpdate user_id, project._id, path, source, (err)->
 					callback(err)
 
 

+ 2 - 2
services/web/app/coffee/Features/ThirdPartyDataStore/UpdateMerger.coffee

@@ -32,13 +32,13 @@ module.exports =
 					else
 						self.p.processDoc project_id, elementId, user_id, fsPath, path, source, callback
 
-	deleteUpdate: (project_id, path, source, callback)->
+	deleteUpdate: (user_id, project_id, path, source, callback)->
 		projectLocator.findElementByPath project_id, path, (err, element, type)->
 			if  err? || !element?
 				logger.log element:element, project_id:project_id, path:path, "could not find entity for deleting, assuming it was already deleted"
 				return callback()
 			logger.log project_id:project_id, path:path, type:type, element:element, "processing update to delete entity from tpds"
-			editorController.deleteEntity project_id, element._id, type, source, (err)->
+			editorController.deleteEntity project_id, element._id, type, source, user_id, (err)->
 				logger.log project_id:project_id, path:path, "finished processing update to delete entity from tpds"
 				callback()
 

+ 41 - 40
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -12,7 +12,7 @@ Modules = require "./Modules"
 Url = require "url"
 PackageVersions = require "./PackageVersions"
 htmlEncoder = new require("node-html-encoder").Encoder("numerical")
-fingerprints = {}
+hashedFiles = {}
 Path = require 'path'
 Features = require "./Features"
 
@@ -30,43 +30,42 @@ getFileContent = (filePath)->
 	filePath = Path.join __dirname, "../../../", "public#{filePath}"
 	exists = fs.existsSync filePath
 	if exists
-		content = fs.readFileSync filePath
+		content = fs.readFileSync filePath, "UTF-8"
 		return content
 	else
-		logger.log filePath:filePath, "file does not exist for fingerprints"
+		logger.log filePath:filePath, "file does not exist for hashing"
 		return ""
 
-logger.log "Generating file fingerprints..."
 pathList = [
-	["#{jsPath}libs/#{fineuploader}.js"]
-	["#{jsPath}libs/require.js"]
-	["#{jsPath}ide.js"]
-	["#{jsPath}main.js"]
-	["#{jsPath}libs.js"]
-	["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"]
-	["#{jsPath}libs/#{pdfjs}/pdf.js"]
-	["#{jsPath}libs/#{pdfjs}/pdf.worker.js"]
-	["#{jsPath}libs/#{pdfjs}/compatibility.js"]
-	["/stylesheets/style.css"]
-	["/stylesheets/ol-style.css"]
+	"#{jsPath}libs/require.js"
+	"#{jsPath}ide.js"
+	"#{jsPath}main.js"
+	"#{jsPath}libraries.js"
+	"/stylesheets/style.css"
+	"/stylesheets/ol-style.css"
 ]
 
-for paths in pathList
-	contentList = _.map(paths, getFileContent)
-	content = contentList.join("")
-	hash = crypto.createHash("md5").update(content).digest("hex")
-	_.each paths, (filePath)-> 
-		logger.log "#{filePath}: #{hash}"
-		fingerprints[filePath] = hash
-
-getFingerprint = (path) ->
-	if fingerprints[path]?
-		return fingerprints[path]
-	else
-		logger.err "No fingerprint for file: #{path}"
-		return ""
+if !Settings.useMinifiedJs 
+	logger.log "not using minified JS, not hashing static files"
+else
+	logger.log "Generating file hashes..."
+	for path in pathList
+		content = getFileContent(path)
+		hash = crypto.createHash("md5").update(content).digest("hex")
+		
+		splitPath = path.split("/")
+		filenameSplit = splitPath.pop().split(".")
+		filenameSplit.splice(filenameSplit.length-1, 0, hash)
+		splitPath.push(filenameSplit.join("."))
+
+		hashPath = splitPath.join("/")
+		hashedFiles[path] = hashPath
 
-logger.log "Finished generating file fingerprints"
+		fsHashPath = Path.join __dirname, "../../../", "public#{hashPath}"
+		fs.writeFileSync(fsHashPath, content)
+
+
+		logger.log "Finished hashing static content"
 
 cdnAvailable = Settings.cdn?.web?.host?
 darkCdnAvailable = Settings.cdn?.web?.darkHost?
@@ -120,29 +119,35 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
 		res.locals.lib = PackageVersions.lib
 
+
+
 		res.locals.buildJsPath = (jsFile, opts = {})->
 			path = Path.join(jsPath, jsFile)
 
-			doFingerPrint = opts.fingerprint != false
+			if opts.hashedPath && hashedFiles[path]?
+				path = hashedFiles[path]
 
 			if !opts.qs?
 				opts.qs = {}
 
-			if !opts.qs?.fingerprint? and doFingerPrint
-				opts.qs.fingerprint = getFingerprint(path)
-
 			if opts.cdn != false
 				path = Url.resolve(staticFilesBase, path)
 
 			qs = querystring.stringify(opts.qs)
 
+			if opts.removeExtension == true
+				path = path.slice(0,-3)
+				
 			if qs? and qs.length > 0
 				path = path + "?" + qs
 			return path
 
-		res.locals.buildCssPath = (cssFile)->
+		res.locals.buildCssPath = (cssFile, opts)->
 			path = Path.join("/stylesheets/", cssFile)
-			return Url.resolve(staticFilesBase, path) + "?fingerprint=" + getFingerprint(path)
+			if opts?.hashedPath && hashedFiles[path]?
+				hashedPath = hashedFiles[path]
+				return Url.resolve(staticFilesBase, hashedPath)
+			return Url.resolve(staticFilesBase, path)
 
 		res.locals.buildImgPath = (imgFile)->
 			path = Path.join("/img/", imgFile)
@@ -227,10 +232,6 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			return req.query?[field]
 		next()
 
-	webRouter.use (req, res, next)->
-		res.locals.fingerprint = getFingerprint
-		next()
-
 	webRouter.use (req, res, next)->
 		res.locals.formatPrice = SubscriptionFormatters.formatPrice
 		next()

+ 1 - 0
services/web/app/coffee/models/Project.coffee

@@ -56,6 +56,7 @@ ProjectSchema = new Schema
 		read_token      : { type: String }
 		history         :
 			id            : { type: Number }
+			display       : { type: Boolean }
 
 ProjectSchema.statics.getProject = (project_or_id, fields, callback)->
 	if project_or_id._id?

+ 5 - 0
services/web/app/coffee/router.coffee

@@ -197,6 +197,7 @@ module.exports = class Router
 
 		webRouter.get  "/project/:Project_id/updates", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.get  "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
+		webRouter.get  "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 		webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
 
 		webRouter.get  '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
@@ -324,6 +325,10 @@ module.exports = class Router
 				headers: req.headers
 			})
 
+		webRouter.get "/no-cache", (req, res, next)->
+			res.header("Cache-Control", "max-age=0")
+			res.sendStatus(404)
+
 		webRouter.get '/oops-express', (req, res, next) -> next(new Error("Test error"))
 		webRouter.get '/oops-internal', (req, res, next) -> throw new Error("Test error")
 		webRouter.get '/oops-mongo', (req, res, next) ->

+ 13 - 8
services/web/app/views/layout.pug

@@ -23,7 +23,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		link(rel="apple-touch-icon-precomposed", href="/" + settings.brandPrefix + "apple-touch-icon-precomposed.png")
 		link(rel="mask-icon", href="/" + settings.brandPrefix + "mask-favicon.svg", color="#a93529")
 			
-		link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css"))
+		link(rel='stylesheet', href=buildCssPath("/" + settings.brandPrefix + "style.css", {hashedPath:true}))
 
 		block _headLinks
 
@@ -57,7 +57,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 		script(type="text/javascript").
 			window.csrfToken = "#{csrfToken}";
 			
-		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
+		script(src=buildJsPath("libs/jquery-1.11.1.min.js"))
 		script(type="text/javascript").
 			var noCdnKey = "nocdn=true"
 			var cdnBlocked = typeof jQuery === 'undefined'
@@ -68,7 +68,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 
 		block scripts
 
-		script(src=buildJsPath("libs/angular-1.6.4.min.js", {fingerprint:false}))
+		script(src=buildJsPath("libs/angular-1.6.4.min.js"))
 
 		script.
 			window.sharelatex = {
@@ -95,7 +95,11 @@ html(itemscope, itemtype='http://schema.org/Product')
 					cdnDomain : '!{settings.templates.cdnDomain}',
 					indexName : '!{settings.templates.indexName}'
 				}
-				
+
+		- if (settings.overleaf && settings.overleaf.useOLFreeTrial)
+			script.
+				window.redirectToOLFreeTrialUrl = '!{settings.overleaf.host}/users/trial'
+
 	body
 		if(settings.recaptcha)
 			script(src="https://www.google.com/recaptcha/api.js?render=explicit")
@@ -142,9 +146,10 @@ html(itemscope, itemtype='http://schema.org/Product')
 				window.requirejs = {
 					"paths" : {
 						"moment": "libs/#{lib('moment')}",
-						"fineuploader": "libs/#{lib('fineuploader')}"
+						"fineuploader": "libs/#{lib('fineuploader')}",
+						"main": "#{buildJsPath('main.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
+						"libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
 					},
-					"urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
 					"config":{
 						"moment":{
 							"noGlobal": true
@@ -152,9 +157,9 @@ html(itemscope, itemtype='http://schema.org/Product')
 					}
 				};
 			script(
-				data-main=buildJsPath('main.js', {fingerprint:false}),
+				data-main=buildJsPath('main.js', {hashedPath:false}),
 				baseurl=fullJsPath,
-				src=buildJsPath('libs/require.js')
+				src=buildJsPath('libs/require.js', {hashedPath:true})
 			)
 
 		include contact-us-modal

+ 11 - 11
services/web/app/views/project/editor.pug

@@ -98,14 +98,14 @@ block requirejs
 	script(type="text/javascript" src='/socket.io/socket.io.js')
 
 	//- don't use cdn for workers
-	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false})
-	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
-	- var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false,fingerprint:false})
+	- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false})
+	- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false})
+	- var pdfCMapsPath = buildJsPath('/libs/' + lib('pdfjs') + '/bcmaps/', {cdn:false})
 
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
 	//- and doesn't prematurely end the script tag.
 	script#data(type="application/json").
-		!{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState}).replace(/\//g, '\\/')}
+		!{JSON.stringify({userSettings: userSettings, user: user, trackChangesState: trackChangesState, useV2History: useV2History}).replace(/\//g, '\\/')}
 
 	script(type="text/javascript").
 		window.data = JSON.parse($("#data").text());
@@ -126,14 +126,15 @@ block requirejs
 		window.wikiEnabled = #{!!(settings.apis.wiki && settings.apis.wiki.url)};
 		window.requirejs = {
 			"paths" : {
-				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, fingerprint:false, qs:{config:'TeX-AMS_HTML'}})}",
+				"mathjax": "#{buildJsPath('/libs/mathjax/MathJax.js', {cdn:false, qs:{config:'TeX-AMS_HTML'}})}",
 				"moment": "libs/#{lib('moment')}",
 				"pdfjs-dist/build/pdf": "libs/#{lib('pdfjs')}/pdf",
 				"pdfjs-dist/build/pdf.worker": "#{pdfWorkerPath}",
 				"ace": "#{lib('ace')}",
-				"fineuploader": "libs/#{lib('fineuploader')}"
+				"fineuploader": "libs/#{lib('fineuploader')}",
+				"ide": "#{buildJsPath('ide.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
+				"libraries": "#{buildJsPath('libraries.js', {hashedPath:settings.useMinifiedJs, removeExtension:true})}",
 			},
-			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}-#{fingerprint(jsPath + 'libs.js')}",
 			"waitSeconds": 0,
 			"shim": {
 				"pdfjs-dist/build/pdf": {
@@ -155,14 +156,13 @@ block requirejs
 				}
 			}
 		};
-		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
 		window.aceWorkerPath = "#{aceWorkerPath}";
 		window.pdfCMapsPath = "#{pdfCMapsPath}"
 		window.uiConfig = JSON.parse('!{JSON.stringify(uiConfig).replace(/\//g, "\\/")}');
 
 	script(
-		data-main=buildJsPath("ide.js", {fingerprint:false}),
+		data-main=buildJsPath("ide.js", {hashedPath:false}),
 		baseurl=fullJsPath,
-		data-ace-base=buildJsPath(lib('ace'), {fingerprint:false}),
-		src=buildJsPath('libs/require.js')
+		data-ace-base=buildJsPath(lib('ace')),
+		src=buildJsPath('libs/require.js', {hashedPath:true})
 	)

+ 13 - 4
services/web/app/views/project/editor/history.pug

@@ -134,8 +134,17 @@ div#history(ng-show="ui.view == 'history'")
 
 					div.description(ng-click="select()")
 						div.time {{ update.meta.end_ts | formatDate:'h:mm a' }}
-						div.docs(ng-repeat="(doc_id, doc) in update.docs")
-							span.doc {{ doc.entity.name }}
+						div.action.action-edited(ng-if="history.isV2 && update.pathnames.length > 0")
+							| Edited
+						div.docs(ng-repeat="pathname in update.pathnames")
+							.doc {{ pathname }}
+						div.docs(ng-repeat="project_op in update.project_ops")
+							div(ng-if="project_op.rename")
+								.action Renamed
+								.doc {{ project_op.rename.pathname }} &rarr; {{ project_op.rename.newPathname }}
+							div(ng-if="project_op.add")
+								.action Created
+								.doc {{ project_op.add.pathname }}
 						div.users
 							div.user(ng-repeat="update_user in update.meta.users")
 								.color-square(ng-if="update_user != null", ng-style="{'background-color': 'hsl({{ update_user.hue }}, 70%, 50%)'}")
@@ -165,8 +174,8 @@ div#history(ng-show="ui.view == 'history'")
 							'other': 'changes'\
 						}"
 					)
-					|  in <strong>{{history.diff.doc.name}}</strong>
-				.toolbar-right
+					|  in <strong>{{history.diff.pathname}}</strong>
+				.toolbar-right(ng-if="!history.isV2")
 					a.btn.btn-danger.btn-sm(
 						href,
 						ng-click="openRestoreDiffModal()"

+ 1 - 0
services/web/app/views/project/list/item.pug

@@ -2,6 +2,7 @@
 	input.select-item(
 		select-individual,
 		type="checkbox",
+		ng-disabled="shouldDisableCheckbox(project)",
 		ng-model="project.selected"
 		stop-propagation="click"
 		aria-label=translate('select_project') + " '{{ project.name }}'"

+ 0 - 0
services/web/bin/compile_app → services/web/bin/compile_backend


+ 5 - 0
services/web/bin/compile_frontend

@@ -0,0 +1,5 @@
+#!/bin/bash
+set -e;
+COFFEE=node_modules/.bin/coffee
+echo Compiling public/coffee;
+$COFFEE -o public/js -c public/coffee;

+ 5 - 0
services/web/bin/compile_frontend_tests

@@ -0,0 +1,5 @@
+#!/bin/bash
+set -e;
+COFFEE=node_modules/.bin/coffee
+echo Compiling test/unit_frontend/coffee;
+$COFFEE -o test/unit_frontend/js -c test/unit_frontend/coffee;

+ 5 - 0
services/web/bin/frontend_test

@@ -0,0 +1,5 @@
+#!/bin/bash
+set -e;
+MOCHA="node_modules/.bin/mocha --recursive --reporter spec"
+$MOCHA "$@" test/unit_frontend/js
+

+ 2 - 1
services/web/config/settings.defaults.coffee

@@ -111,7 +111,8 @@ module.exports = settings =
 		trackchanges:
 			url : "http://localhost:3015"
 		project_history:
-			enabled: process.env.PROJECT_HISTORY_ENABLED == 'true' or false
+			sendProjectStructureOps: process.env.PROJECT_HISTORY_ENABLED == 'true' or false
+			initializeHistoryForNewProjects: process.env.PROJECT_HISTORY_ENABLED == 'true' or false
 			url : "http://localhost:3054"
 		docstore:
 			url : "http://#{process.env['DOCSTORE_HOST'] or 'localhost'}:3016"

+ 3 - 5
services/web/docker-shared.template.yml

@@ -13,17 +13,15 @@ services:
       - ./npm-shrinkwrap.json:/app/npm-shrinkwrap.json
       - node_modules:/app/node_modules
       - ./bin:/app/bin
-      # Copying the whole public dir is fine for now, and needed for
-      # some unit tests to pass, but we will want to isolate the coffee
-      # and vendor js files, so that the compiled js files are not written
-      # back to the local filesystem.
-      - ./public:/app/public
+      - ./public/coffee:/app/public/coffee:ro
+      - ./public/js/ace-1.2.5:/app/public/js/ace-1.2.5
       - ./app.coffee:/app/app.coffee:ro
       - ./app/coffee:/app/app/coffee:ro
       - ./app/templates:/app/app/templates:ro
       - ./app/views:/app/app/views:ro
       - ./config:/app/config
       - ./test/unit/coffee:/app/test/unit/coffee:ro
+      - ./test/unit_frontend/coffee:/app/test/unit_frontend/coffee:ro
       - ./test/acceptance/coffee:/app/test/acceptance/coffee:ro
       - ./test/acceptance/files:/app/test/acceptance/files:ro
       - ./test/smoke/coffee:/app/test/smoke/coffee:ro

+ 7 - 3
services/web/package.json

@@ -14,11 +14,15 @@
     "test:acceptance:run": "bin/acceptance_test $@",
     "test:acceptance:dir": "npm -q run compile:acceptance_tests && npm -q run test:acceptance:wait_for_app && npm -q run test:acceptance:run -- $@",
     "test:acceptance": "npm -q run test:acceptance:dir -- $@ test/acceptance/js",
-    "test:unit": "npm -q run compile:app && npm -q run compile:unit_tests && bin/unit_test $@",
+    "test:unit": "npm -q run compile:backend && npm -q run compile:unit_tests && bin/unit_test $@",
+    "test:frontend": "npm -q run compile:frontend && npm -q run compile:frontend_tests && bin/frontend_test $@",
     "compile:unit_tests": "bin/compile_unit_tests",
+    "compile:frontend_tests": "bin/compile_frontend_tests",
     "compile:acceptance_tests": "bin/compile_acceptance_tests",
-    "compile:app": "bin/compile_app",
-    "start": "npm -q run compile:app && node app.js"
+    "compile:frontend": "bin/compile_frontend",
+    "compile:backend": "bin/compile_backend",
+    "compile": "npm -q run compile:backend && npm -q run compile:frontend",
+    "start": "npm -q run compile && node app.js"
   },
   "dependencies": {
     "archiver": "0.9.0",

+ 1 - 1
services/web/public/coffee/base.coffee

@@ -1,5 +1,5 @@
 define [
-	"libs"
+	"libraries"
 	"modules/recursionHelper"
 	"modules/errorCatcher"
 	"modules/localStorage"

+ 4 - 1
services/web/public/coffee/directives/selectAll.coffee

@@ -49,18 +49,21 @@ define [
 						selectAllListController.clearSelectAllState()
 
 				scope.$on "select-all:select", () ->
+					return if element.prop('disabled')
 					ignoreChanges = true
 					scope.$apply () ->
 						scope.ngModel = true
 					ignoreChanges = false
 
 				scope.$on "select-all:deselect", () ->
+					return if element.prop('disabled')
 					ignoreChanges = true
 					scope.$apply () ->
 						scope.ngModel = false
 					ignoreChanges = false
 
 				scope.$on "select-all:row-clicked", () ->
+					return if element.prop('disabled')
 					ignoreChanges = true
 					scope.$apply () ->
 						scope.ngModel = !scope.ngModel
@@ -75,4 +78,4 @@ define [
 			link: (scope, element, attrs) ->
 				element.on "click", (e) ->
 					scope.$broadcast "select-all:row-clicked"
-		}
+		}

+ 6 - 1
services/web/public/coffee/ide.coffee

@@ -5,6 +5,7 @@ define [
 	"ide/editor/EditorManager"
 	"ide/online-users/OnlineUsersManager"
 	"ide/history/HistoryManager"
+	"ide/history/HistoryV2Manager"
 	"ide/permissions/PermissionsManager"
 	"ide/pdf/PdfManager"
 	"ide/binary-files/BinaryFilesManager"
@@ -44,6 +45,7 @@ define [
 	EditorManager
 	OnlineUsersManager
 	HistoryManager
+	HistoryV2Manager
 	PermissionsManager
 	PdfManager
 	BinaryFilesManager
@@ -137,7 +139,10 @@ define [
 		ide.fileTreeManager = new FileTreeManager(ide, $scope)
 		ide.editorManager = new EditorManager(ide, $scope)
 		ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
-		ide.historyManager = new HistoryManager(ide, $scope)
+		if window.data.useV2History
+			ide.historyManager = new HistoryV2Manager(ide, $scope)
+		else
+			ide.historyManager = new HistoryManager(ide, $scope)
 		ide.pdfManager = new PdfManager(ide, $scope)
 		ide.permissionsManager = new PermissionsManager(ide, $scope)
 		ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)

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

@@ -33,7 +33,7 @@ define [
 	if !ace.config._moduleUrl?
 		ace.config._moduleUrl = ace.config.moduleUrl
 		ace.config.moduleUrl = (args...) ->
-			url = ace.config._moduleUrl(args...) + "?fingerprint=#{window.aceFingerprint}"
+			url = ace.config._moduleUrl(args...)
 			return url
 
 	App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, metadata, graphics, preamble, files, $http, $q) ->

+ 3 - 0
services/web/public/coffee/ide/history/HistoryManager.coffee

@@ -100,6 +100,7 @@ define [
 				end_ts:   end_ts
 				doc:      doc
 				error:    false
+				pathname: doc.name
 			}
 
 			if !doc.deleted
@@ -190,8 +191,10 @@ define [
 			previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
 
 			for update in updates
+				update.pathnames = [] # Used for display
 				for doc_id, doc of update.docs or {}
 					doc.entity = @ide.fileTreeManager.findEntityById(doc_id, includeDeleted: true)
+					update.pathnames.push doc.entity.name
 
 				for user in update.meta.users or []
 					if user?

+ 280 - 0
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -0,0 +1,280 @@
+define [
+	"moment"
+	"ide/colors/ColorManager"
+	"ide/history/controllers/HistoryListController"
+	"ide/history/controllers/HistoryDiffController"
+	"ide/history/directives/infiniteScroll"
+], (moment, ColorManager) ->
+	class HistoryManager
+		constructor: (@ide, @$scope) ->
+			@reset()
+
+			@$scope.toggleHistory = () =>
+				if @$scope.ui.view == "history"
+					@hide()
+				else
+					@show()
+
+			@$scope.$watch "history.selection.updates", (updates) =>
+				if updates? and updates.length > 0
+					@_selectDocFromUpdates()
+					@reloadDiff()
+
+			@$scope.$on "entity:selected", (event, entity) =>
+				if (@$scope.ui.view == "history") and (entity.type == "doc")
+					@$scope.history.selection.pathname = _ide.fileTreeManager.getEntityPath(entity)
+					@reloadDiff()
+
+		show: () ->
+			@$scope.ui.view = "history"
+			@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.history = {
+				isV2: true
+				updates: []
+				nextBeforeTimestamp: null
+				atEnd: false
+				selection: {
+					updates: []
+					pathname: null
+					range: {
+						fromV: null
+						toV: null
+					}
+				}
+				diff: null
+			}
+
+		MAX_RECENT_UPDATES_TO_SELECT: 2
+		autoSelectRecentUpdates: () ->
+			return if @$scope.history.updates.length == 0
+
+			@$scope.history.updates[0].selectedTo = true
+
+			indexOfLastUpdateNotByMe = 0
+			for update, i in @$scope.history.updates
+				if @_updateContainsUserId(update, @$scope.user.id) or i > @MAX_RECENT_UPDATES_TO_SELECT
+					break
+				indexOfLastUpdateNotByMe = i
+
+			@$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
+
+		BATCH_SIZE: 10
+		fetchNextBatchOfUpdates: () ->
+			url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
+			if @$scope.history.nextBeforeTimestamp?
+				url += "&before=#{@$scope.history.nextBeforeTimestamp}"
+			@$scope.history.loading = true
+			@ide.$http
+				.get(url)
+				.then (response) =>
+					{ data } = response
+					@_loadUpdates(data.updates)
+					@$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
+					if !data.nextBeforeTimestamp?
+						@$scope.history.atEnd = true
+					@$scope.history.loading = false
+
+		reloadDiff: () ->
+			diff = @$scope.history.diff
+			{updates} = @$scope.history.selection
+			{fromV, toV, pathname} = @_calculateDiffDataFromSelection()
+
+			if !pathname?
+				@$scope.history.diff = null
+				return
+
+			return if diff? and
+				diff.pathname == pathname and
+				diff.fromV    == fromV and
+				diff.toV      == toV
+
+			@$scope.history.diff = diff = {
+				fromV:    fromV
+				toV:      toV
+				pathname: pathname
+				error:    false
+			}
+
+			diff.loading = true
+			url = "/project/#{@$scope.project_id}/diff"
+			query = ["pathname=#{encodeURIComponent(pathname)}"]
+			if diff.fromV? and diff.toV?
+				query.push "from=#{diff.fromV}", "to=#{diff.toV}"
+			url += "?" + query.join("&")
+
+			@ide.$http
+				.get(url)
+				.then (response) =>
+					{ data } = response
+					diff.loading = false
+					{text, highlights} = @_parseDiff(data)
+					diff.text = text
+					diff.highlights = highlights
+				.catch () ->
+					diff.loading = false
+					diff.error = true
+
+		_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: ColorManager.getHueForUserId(entry.meta.user?.id)
+						}
+					else if entry.d?
+						highlights.push {
+							label: "Deleted by #{name} on #{date}"
+							strikeThrough: range
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
+						}
+
+			return {text, highlights}
+
+		_loadUpdates: (updates = []) ->
+			previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
+
+			for update in updates or []
+				for user in update.meta.users or []
+					if user?
+						user.hue = ColorManager.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.history.updates.length == 0
+
+			@$scope.history.updates =
+				@$scope.history.updates.concat(updates)
+
+			@autoSelectRecentUpdates() if firstLoad
+
+		_perDocSummaryOfUpdates: (updates) ->
+			# Track current_pathname -> original_pathname
+			original_pathnames = {}
+
+			# Map of original pathname -> doc summary
+			docs_summary = {}
+
+			updatePathnameWithUpdateVersions = (pathname, update) ->
+				# docs_summary is indexed by the original pathname the doc
+				# had at the start, so we have to look this up from the current
+				# pathname via original_pathname first
+				if !original_pathnames[pathname]?
+						original_pathnames[pathname] = pathname
+				original_pathname = original_pathnames[pathname]
+				doc_summary = docs_summary[original_pathname] ?= {
+					fromV: update.fromV, toV: update.toV,
+				}
+				doc_summary.fromV = Math.min(
+					doc_summary.fromV,
+					update.fromV
+				)
+				doc_summary.toV = Math.max(
+					doc_summary.toV,
+					update.toV
+				)
+
+			# Put updates in ascending chronological order
+			updates = updates.slice().reverse()
+			for update in updates
+				for pathname in update.pathnames or []
+					updatePathnameWithUpdateVersions(pathname, update)
+				for project_op in update.project_ops or []
+					if project_op.rename?
+						rename = project_op.rename
+						updatePathnameWithUpdateVersions(rename.pathname, update)
+						original_pathnames[rename.newPathname] = original_pathnames[rename.pathname]
+						delete original_pathnames[rename.pathname]
+					if project_op.add?
+						add = project_op.add
+						updatePathnameWithUpdateVersions(add.pathname, update)
+
+			return docs_summary
+
+		_calculateDiffDataFromSelection: () ->
+			fromV = toV = pathname = null
+
+			selected_pathname = @$scope.history.selection.pathname
+
+			for pathname, doc of @_perDocSummaryOfUpdates(@$scope.history.selection.updates)
+				if pathname == selected_pathname
+					{fromV, toV} = doc
+					return {fromV, toV, pathname}
+
+			return {}
+
+		# 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 = @_perDocSummaryOfUpdates(@$scope.history.selection.updates)
+
+			selected_pathname = @$scope.history.selection.pathname
+			if selected_pathname? and affected_docs[selected_pathname]
+				# Selected doc is already open
+			else
+				# Set to first possible candidate
+				for pathname, doc of affected_docs
+					selected_pathname = pathname
+					break
+
+			@$scope.history.selection.pathname = selected_pathname
+			if selected_pathname?
+				entity = @ide.fileTreeManager.findEntityByPath(selected_pathname)
+				if entity?
+					@ide.fileTreeManager.selectEntity(entity)
+
+		_updateContainsUserId: (update, user_id) ->
+			for user in update.meta.users
+				return true if user?.id == user_id
+			return false

+ 3 - 9
services/web/public/coffee/ide/pdf/controllers/PdfController.coffee

@@ -90,7 +90,9 @@ define [
 				# to block auto compiles. It also causes problems where server-provided
 				# linting errors aren't cleared after typing
 				if (ide.$scope.settings.syntaxValidation and !ide.$scope.hasLintingError)
-					$scope.recompile(isAutoCompileOnChange: true)
+					$scope.recompile(isAutoCompileOnChange: true) # compile if no linting errors
+				else if !ide.$scope.settings.syntaxValidation
+					$scope.recompile(isAutoCompileOnChange: true) # always recompile
 			else
 				# Extend remainder of timeout
 				autoCompileTimeout = setTimeout () ->
@@ -533,14 +535,6 @@ define [
 		else
 			$scope.switchToSideBySideLayout()
 
-		$scope.startFreeTrial = (source) ->
-			ga?('send', 'event', 'subscription-funnel', 'compile-timeout', source)
-
-			event_tracking.sendMB "subscription-start-trial", { source }
-
-			window.open("/user/subscription/new?planCode=#{$scope.startTrialPlanCode}")
-			$scope.startedFreeTrial = true
-
 	App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
 		# enable per-user containers by default
 		perUserCompile = true

+ 0 - 5
services/web/public/coffee/ide/review-panel/controllers/TrackChangesUpgradeModalController.coffee

@@ -4,8 +4,3 @@ define [
 	App.controller "TrackChangesUpgradeModalController", ($scope, $modalInstance) ->
 		$scope.cancel = () ->
 			$modalInstance.dismiss()
-			
-		$scope.startFreeTrial = (source) ->
-			ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
-			window.open("/user/subscription/new?planCode=student_free_trial_7_days")
-			$scope.startedFreeTrial = true

+ 0 - 0
services/web/public/coffee/libs.coffee → services/web/public/coffee/libraries.coffee


+ 7 - 4
services/web/public/coffee/main/account-upgrade.coffee

@@ -11,9 +11,12 @@ define [
 			w = window.open()
 			go = () ->
 				ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
-				url = "/user/subscription/new?planCode=#{plan}&ssp=true"
-				if couponCode?
-					url = "#{url}&cc=#{couponCode}"
+				if window.redirectToOLFreeTrialUrl?
+					url = window.redirectToOLFreeTrialUrl
+				else
+					url = "/user/subscription/new?planCode=#{plan}&ssp=true"
+					if couponCode?
+						url = "#{url}&cc=#{couponCode}"
 				$scope.startedFreeTrial = true
 
 				switch source
@@ -27,7 +30,7 @@ define [
 
 					else
 						event_tracking.sendMB "subscription-start-trial", { source, plan }
-				
+
 				w.location = url
 
 			if $scope.shouldABTestPlans

+ 3 - 0
services/web/public/coffee/main/project-list/project-list.coffee

@@ -462,6 +462,9 @@ define [
 
 	App.controller "ProjectListItemController", ($scope) ->
 
+		$scope.shouldDisableCheckbox = (project) ->
+			$scope.filter == 'archived' && project.accessLevel != 'owner'
+
 		$scope.projectLink = (project) ->
 			if project.accessLevel == 'readAndWrite' and project.source == 'token'
 				"/#{project.tokens.readAndWrite}"

+ 1 - 1
services/web/public/coffee/utils/underscore.coffee

@@ -1,5 +1,5 @@
 define [
-	"libs"
+	"libraries"
 ], () ->
 	angular.module('underscore', []).factory '_', ->
 		return window._

+ 12 - 2
services/web/public/stylesheets/app/editor/history.less

@@ -169,9 +169,19 @@
 					font-size: 0.8rem;
 					line-height: @line-height-computed;
 				}
-				.docs {
-					font-weight: bold;
+				.doc {
 					font-size: 0.9rem;
+					font-weight: bold;
+				}
+				.action {
+					color: @gray;
+					text-transform: uppercase;
+					font-size: 0.7em;
+					margin-bottom: -2px;
+					margin-top: 2px;
+					&-edited {
+						margin-top: 0;
+					}
 				}
 			}
 			li.loading-changes, li.empty-message {

+ 32 - 19
services/web/public/stylesheets/app/editor/pdf.less

@@ -10,9 +10,13 @@
 	padding: 0 (@line-height-computed / 2);
 }
 
+.pdf {
+	background-color: @pdf-bg;
+}
+
 .pdf-viewer, .pdf-logs, .pdf-errors, .pdf-uncompiled {
 	.full-size;
-	top: 58px;
+	top: @pdf-top-offset;
 }
 
 .pdf-logs, .pdf-errors, .pdf-uncompiled, .pdf-validation-problems{
@@ -69,11 +73,11 @@
 	}
 	.pdfjs-viewer {
 		.full-size;
-		background-color: @gray-lighter;
+		background-color: @pdfjs-bg;
 		overflow: scroll;
 		canvas, div.pdf-canvas {
 			background: white;
-			box-shadow: black 0px 0px 10px;
+			box-shadow: @pdf-page-shadow-color 0px 0px 10px;
 		}
 		div.pdf-canvas.pdfng-empty {
 			background-color: white;
@@ -179,7 +183,7 @@
 		cursor: pointer;
 		.line-no {
 			float: right;
-			color: @gray;
+			color: @log-line-no-color;
 			font-weight: 700;
 
 			.fa {
@@ -203,16 +207,25 @@
 			}
 		}
 
-		&.alert-danger:hover {
-			background-color: darken(@alert-danger-bg, 5%);
+		&.alert-danger {
+			background-color: tint(@alert-danger-bg, 15%);
+			&:hover {
+				background-color: @alert-danger-bg;
+			}
 		}
 
-		&.alert-warning:hover {
-			background-color: darken(@alert-warning-bg, 5%);
+		&.alert-warning {
+			background-color: tint(@alert-warning-bg, 15%);
+			&:hover {
+				background-color: @alert-warning-bg;
+			}
 		}
 
-		&.alert-info:hover {
-			background-color: darken(@alert-info-bg, 5%);
+		&.alert-info {
+			background-color: tint(@alert-info-bg, 15%);
+			&:hover {
+				background-color: @alert-info-bg;
+			}
 		}
 
 	}
@@ -354,22 +367,22 @@
 		}
 
 		.alert-danger & {
-			color: @alert-danger-border;
+			color: @state-danger-border;
 		}
 
 		.alert-warning & {
-			color: @alert-warning-border;
+			color: @state-warning-border;
 		}
 
 		.alert-info & {
-			color: @alert-info-border;
+			color: @state-info-border;
 		}
 
 	}
 
 	&-text, 
 	&-feedback-label {
-		color: @gray-dark;
+		color: @log-hints-color;
 		font-size: 0.9rem;
 		margin-bottom: 20px;
 	}
@@ -394,25 +407,25 @@
 	&-actions a,
 	&-text a {
 		.alert-danger & {
-			color: @alert-danger-text;
+			color: @state-danger-text;
 		}
 
 		.alert-warning & {
-			color: @alert-warning-text;
+			color: @state-warning-text;
 		}
 
 		.alert-info & {
-			color: @alert-info-text;
+			color: @state-info-text;
 		}
 	}
 
 	&-feedback {
-		color: @gray-dark;
+		color: @log-hints-color;
 		float: right;
 	}
 
 	&-extra-feedback {
-		color: @gray-dark;
+		color: @log-hints-color;
 		font-size: 0.8rem;
 		margin-top: 10px;
 		padding-bottom: 5px;

+ 2 - 2
services/web/public/stylesheets/app/editor/toolbar.less

@@ -129,11 +129,11 @@
 }
 
 .toolbar-small-mixin() {
-	height: 32px;
+	height: @toolbar-small-height;
 }
 
 .toolbar-tall-mixin() {
-	height: 58px;
+	height: @toolbar-tall-height;
 	padding-top: 10px;
 }
 .toolbar-alt-mixin() {

+ 1 - 1
services/web/public/stylesheets/components/alerts.less

@@ -10,7 +10,7 @@
   padding: @alert-padding;
   margin-bottom: @line-height-computed;
   border-left: 3px solid transparent;
-  // border-radius: @alert-border-radius;
+  border-radius: @alert-border-radius;
 
   // Headings for larger alerts
   h4 {

+ 0 - 1
services/web/public/stylesheets/components/card.less

@@ -1,7 +1,6 @@
 .card {
   background-color: white;
   border-radius: @border-radius-base;
-  -webkit-box-shadow: @card-box-shadow;
   box-shadow: @card-box-shadow;
   padding: @line-height-computed;
   .page-header {

+ 23 - 13
services/web/public/stylesheets/core/_common-variables.less

@@ -545,7 +545,7 @@
 //## Define alert colors, border radius, and padding.
 
 @alert-padding:               15px;
-@alert-border-radius:         @border-radius-base;
+@alert-border-radius:         0;
 @alert-link-font-weight:      bold;
 
 @alert-success-bg:            @state-success-bg;
@@ -899,20 +899,22 @@
 @toolbar-icon-btn-hover-color   : @gray-dark;
 @toolbar-icon-btn-hover-shadow  : 0 1px 0 rgba(0, 0, 0, 0.25);
 @toolbar-icon-btn-hover-boxshadow : inset 0 3px 5px rgba(0, 0, 0, 0.225);
-@toolbar-border-bottom          : 1px solid @toolbar-border-color;
+@toolbar-border-bottom            : 1px solid @toolbar-border-color;
+@toolbar-small-height             : 32px;
+@toolbar-tall-height              : 58px;
 
 // Editor file-tree
-@file-tree-bg                   : transparent;
-@file-tree-line-height          : 2.6;
-@file-tree-item-color           : @gray-darker;
-@file-tree-item-toggle-color    : @gray;
-@file-tree-item-icon-color      : @gray-light;
-@file-tree-item-input-color     : inherit;
-@file-tree-item-folder-color    : lighten(desaturate(@link-color, 10%), 5%);
-@file-tree-item-hover-bg        : @gray-lightest;
-@file-tree-item-selected-bg     : transparent;
-@file-tree-multiselect-bg       : lighten(@brand-info, 40%);
-@file-tree-multiselect-hover-bg : lighten(@brand-info, 30%);
+@file-tree-bg                    : transparent;
+@file-tree-line-height           : 2.6;
+@file-tree-item-color            : @gray-darker;
+@file-tree-item-toggle-color     : @gray;
+@file-tree-item-icon-color       : @gray-light;
+@file-tree-item-input-color      : inherit;
+@file-tree-item-folder-color     : lighten(desaturate(@link-color, 10%), 5%);
+@file-tree-item-hover-bg         : @gray-lightest;
+@file-tree-item-selected-bg      : transparent;
+@file-tree-multiselect-bg        : lighten(@brand-info, 40%);
+@file-tree-multiselect-hover-bg  : lighten(@brand-info, 30%);
 
 // Editor resizers
 @editor-resizer-bg-color          : #F4F4F4;
@@ -935,6 +937,14 @@
 @chat-new-message-textarea-bg     : #FFF;
 @chat-new-message-textarea-color  : @gray-dark;
 
+// PDF
+@pdf-top-offset                  : @toolbar-tall-height;
+@pdf-bg                          : transparent;
+@pdfjs-bg                        : @gray-lighter;
+@pdf-page-shadow-color           : #000;
+@log-line-no-color               : @gray; 
+@log-hints-color                 : @gray-dark;
+
 // Tags
 @tag-border-radius  : 0.25em;
 @tag-bg-color       : @label-default-bg;

+ 50 - 14
services/web/public/stylesheets/core/ol-variables.less

@@ -67,6 +67,28 @@
 @btn-info-bg             : @ol-blue;
 @btn-info-border         : transparent;
 
+// Alerts
+@alert-padding : 15px;
+@alert-border-radius : @border-radius-base;
+@alert-link-font-weight : bold;
+
+@alert-success-bg    : @brand-success;
+@alert-success-text  : #FFF;
+@alert-success-border: transparent;
+
+@alert-info-bg       : @brand-info;
+@alert-info-text     : #FFF;
+@alert-info-border   : transparent;
+
+@alert-warning-bg    : @brand-warning;
+@alert-warning-text  : #FFF;
+@alert-warning-border: transparent;
+
+@alert-danger-bg     : @brand-danger;
+@alert-danger-text   : #FFF;
+@alert-danger-border : transparent;
+
+
 // Tags
 @tag-border-radius       : 9999px;
 @tag-bg-color            : @ol-green;
@@ -179,21 +201,25 @@
 @toolbar-icon-btn-hover-shadow    : none;
 @toolbar-border-bottom            : 1px solid @toolbar-border-color;
 @toolbar-icon-btn-hover-boxshadow : none;
+<<<<<<< HEAD
 @toolbar-font-size                : 13px;
+=======
+>>>>>>> master
 
 // Editor file-tree
-@file-tree-bg                     : @ol-blue-gray-4;
-@file-tree-line-height            : 2.05;
-@file-tree-item-color             : #FFF;
-@file-tree-item-input-color       : @ol-blue-gray-5;
-@file-tree-item-toggle-color      : @ol-blue-gray-2;
-@file-tree-item-icon-color        : @ol-blue-gray-2;
-@file-tree-item-folder-color      : @ol-blue-gray-2;
-@file-tree-item-hover-bg          : @ol-blue-gray-5;
-@file-tree-item-selected-bg       : @ol-green;
-@file-tree-multiselect-bg         : @ol-blue;
-@file-tree-multiselect-hover-bg   : @ol-dark-blue;
-@file-tree-droppable-bg-color     : tint(@ol-green, 5%);
+@file-tree-bg                   : @ol-blue-gray-4;
+@file-tree-line-height          : 2.05;
+@file-tree-item-color           : #FFF;
+@file-tree-item-input-color     : @ol-blue-gray-5;
+@file-tree-item-toggle-color    : @ol-blue-gray-2;
+@file-tree-item-icon-color      : @ol-blue-gray-2;
+@file-tree-item-folder-color    : @ol-blue-gray-2;
+@file-tree-item-hover-bg        : @ol-blue-gray-5;
+@file-tree-item-selected-bg     : @ol-green;
+@file-tree-multiselect-bg       : @ol-blue;
+@file-tree-multiselect-hover-bg : @ol-dark-blue;
+@file-tree-droppable-bg-color   : tint(@ol-green, 5%);
+
 // Editor resizers
 @editor-resizer-bg-color          : @ol-blue-gray-6;
 @editor-resizer-bg-color-dragging : transparent;
@@ -216,6 +242,16 @@
 @chat-new-message-textarea-bg     : @ol-blue-gray-1;
 @chat-new-message-textarea-color  : @ol-blue-gray-6;
 
+// PDF
+@pdf-top-offset                 : @toolbar-small-height;
+@pdf-bg                         : @ol-blue-gray-1;
+@pdfjs-bg                       : transparent;
+@pdf-page-shadow-color          : rgba(0, 0, 0, 0.5);
+@log-line-no-color              : #FFF;
+@log-hints-color                : @ol-blue-gray-4;
+
+//== Colors
+//
 //## Gray and brand colors for use across Bootstrap.
 @gray-darker:           #252525;
 @gray-dark:             #505050;
@@ -235,9 +271,9 @@
 
 @brand-primary:         @ol-green;
 @brand-success:         @green;
-@brand-info:            @ol-dark-green;
+@brand-info:            @ol-blue;
 @brand-warning:         @orange;
-@brand-danger:          #E03A06;
+@brand-danger:          @ol-red;
 
 @editor-loading-logo-padding-top: 115.44%;
 @editor-loading-logo-background-url: url(/img/ol-brand/overleaf-o-grey.svg);

+ 140 - 1
services/web/test/acceptance/coffee/ProjectStructureTests.coffee

@@ -59,7 +59,7 @@ describe "ProjectStructureChanges", ->
 				@dup_project_id = body.project_id
 				done()
 
-		it "should version the dosc created", ->
+		it "should version the docs created", ->
 			updates = MockDocUpdaterApi.getProjectStructureUpdates(@dup_project_id).docUpdates
 			expect(updates.length).to.equal(2)
 			_.each updates, (update) =>
@@ -91,6 +91,7 @@ describe "ProjectStructureChanges", ->
 					throw error if error?
 					if res.statusCode < 200 || res.statusCode >= 300
 						throw new Error("failed to add doc #{res.statusCode}")
+					@example_doc_id = body._id
 					done()
 
 		it "should version the doc added", ->
@@ -162,6 +163,8 @@ describe "ProjectStructureChanges", ->
 				if res.statusCode < 200 || res.statusCode >= 300
 					throw new Error("failed to upload file #{res.statusCode}")
 
+				@example_file_id = JSON.parse(body).entity_id
+
 				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
 				expect(updates.length).to.equal(1)
 				update = updates[0]
@@ -199,6 +202,120 @@ describe "ProjectStructureChanges", ->
 
 				done()
 
+	describe "moving entities", ->
+		before (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/folder",
+				formData:
+					name: 'foo'
+			}, (error, res, body) =>
+				throw error if error?
+				@example_folder_id_1 = JSON.parse(body)._id
+				done()
+
+		beforeEach () ->
+			MockDocUpdaterApi.clearProjectStructureUpdates()
+
+		it "should version moving a doc", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/Doc/#{@example_doc_id}/move",
+				json:
+					folder_id: @example_folder_id_1
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to move doc #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/new.tex")
+				expect(update.newPathname).to.equal("/foo/new.tex")
+
+				done()
+
+		it "should version moving a file", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/File/#{@example_file_id}/move",
+				json:
+					folder_id: @example_folder_id_1
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to move file #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/1pixel.png")
+				expect(update.newPathname).to.equal("/foo/1pixel.png")
+
+				done()
+
+		it "should version moving a folder", (done) ->
+			@owner.request.post {
+				uri: "project/#{@example_project_id}/folder",
+				formData:
+					name: 'bar'
+			}, (error, res, body) =>
+				throw error if error?
+				@example_folder_id_2 = JSON.parse(body)._id
+
+				@owner.request.post {
+					uri: "project/#{@example_project_id}/Folder/#{@example_folder_id_1}/move",
+					json:
+						folder_id: @example_folder_id_2
+				}, (error, res, body) =>
+					throw error if error?
+					if res.statusCode < 200 || res.statusCode >= 300
+						throw new Error("failed to move folder #{res.statusCode}")
+
+					updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+					expect(updates.length).to.equal(1)
+					update = updates[0]
+					expect(update.userId).to.equal(@owner._id)
+					expect(update.pathname).to.equal("/foo/new.tex")
+					expect(update.newPathname).to.equal("/bar/foo/new.tex")
+
+					updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+					expect(updates.length).to.equal(1)
+					update = updates[0]
+					expect(update.userId).to.equal(@owner._id)
+					expect(update.pathname).to.equal("/foo/1pixel.png")
+					expect(update.newPathname).to.equal("/bar/foo/1pixel.png")
+
+					done()
+
+	describe "deleting entities", ->
+		beforeEach () ->
+			MockDocUpdaterApi.clearProjectStructureUpdates()
+
+		it "should version deleting a folder", (done) ->
+			@owner.request.delete {
+				uri: "project/#{@example_project_id}/Folder/#{@example_folder_id_2}",
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to delete folder #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/bar/foo/new.tex")
+				expect(update.newPathname).to.equal("")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@example_project_id).fileUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/bar/foo/1pixel.png")
+				expect(update.newPathname).to.equal("")
+
+				done()
+
 	describe "tpds", ->
 		before (done) ->
 			@tpds_project_name = "tpds-project-#{new ObjectId().toString()}"
@@ -305,3 +422,25 @@ describe "ProjectStructureChanges", ->
 				done()
 
 			image_file.pipe(req)
+
+		it "should version deleting a doc", (done) ->
+			req = @owner.request.delete {
+				uri: "/user/#{@owner._id}/update/#{@tpds_project_name}/test.tex",
+				auth:
+					user: _.keys(Settings.httpAuthUsers)[0]
+					pass: _.values(Settings.httpAuthUsers)[0]
+					sendImmediately: true
+			}, (error, res, body) =>
+				throw error if error?
+				if res.statusCode < 200 || res.statusCode >= 300
+					throw new Error("failed to delete doc #{res.statusCode}")
+
+				updates = MockDocUpdaterApi.getProjectStructureUpdates(@tpds_project_id).docUpdates
+				expect(updates.length).to.equal(1)
+				update = updates[0]
+				expect(update.userId).to.equal(@owner._id)
+				expect(update.pathname).to.equal("/test.tex")
+				expect(update.newPathname).to.equal("")
+
+				done()
+

+ 3 - 0
services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee

@@ -33,6 +33,9 @@ module.exports = MockDocUpdaterApi =
 			@addProjectStructureUpdates(project_id, userId, docUpdates, fileUpdates)
 			res.sendStatus 200
 
+		app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
+			res.send 204
+
 		app.listen 3003, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 10 - 0
services/web/test/acceptance/coffee/helpers/MockDocstoreApi.coffee

@@ -23,6 +23,16 @@ module.exports = MockDocStoreApi =
 			docs = (doc for doc_id, doc of @docs[req.params.project_id])
 			res.send JSON.stringify docs
 
+		app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
+			{project_id, doc_id} = req.params
+			if !@docs[project_id]?
+				res.send 404
+			else if !@docs[project_id][doc_id]?
+				res.send 404
+			else
+				@docs[project_id][doc_id] = undefined
+				res.send 204
+
 		app.listen 3016, (error) ->
 			throw error if error?
 		.on "error", (error) ->

+ 13 - 5
services/web/test/unit/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee

@@ -393,7 +393,7 @@ describe 'DocumentUpdaterHandler', ->
 
 		describe "with project history disabled", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = false
+				@settings.apis.project_history.sendProjectStructureOps = false
 				@request.post = sinon.stub()
 
 				@handler.updateProjectStructure @project_id, @user_id, {}, @callback
@@ -406,7 +406,7 @@ describe 'DocumentUpdaterHandler', ->
 
 		describe "with project history enabled", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = true
+				@settings.apis.project_history.sendProjectStructureOps = true
 				@url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}"
 				@request.post = sinon.stub().callsArgWith(1, null, {statusCode: 204}, "")
 
@@ -478,14 +478,22 @@ describe 'DocumentUpdaterHandler', ->
 							.should.equal true
 						done()
 
-			describe "when a doc has been deleted", ->
-				it 'should do nothing', (done) ->
+			describe "when an entity has been deleted", ->
+				it 'should end the structure update to the document updater', (done) ->
 					@docId = new ObjectId()
 					@changes = oldDocs: [
 						{ path: '/foo', docLines: 'a\nb', doc: _id: @docId }
 					]
 
+					docUpdates = [
+						id: @docId.toString(),
+						pathname: '/foo',
+						newPathname: ''
+					]
+
 					@handler.updateProjectStructure @project_id, @user_id, @changes, () =>
-						@request.post.called.should.equal false
+						@request.post
+							.calledWith(url: @url, json: {docUpdates, fileUpdates: [], userId: @user_id})
+							.should.equal true
 						done()
 

+ 24 - 29
services/web/test/unit/coffee/Editor/EditorControllerTests.coffee

@@ -376,58 +376,53 @@ describe "EditorController", ->
 				err.should.equal "timed out"
 				done()			
 
-
 	describe "deleteEntity", ->
-
 		beforeEach ->
 			@LockManager.getLock.callsArgWith(1)
 			@LockManager.releaseLock.callsArgWith(1)
-			@EditorController.deleteEntityWithoutLock = sinon.stub().callsArgWith(4)
+			@EditorController.deleteEntityWithoutLock = sinon.stub().callsArgWith(5)
 
 		it "should call deleteEntityWithoutLock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source,  =>
-				@EditorController.deleteEntityWithoutLock.calledWith(@project_id, @entity_id, @type, @source).should.equal true
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, =>
+				@EditorController.deleteEntityWithoutLock
+					.calledWith(@project_id, @entity_id, @type, @source, @user_id)
+					.should.equal true
 				done()
 
 		it "should take the lock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source,  =>
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, =>
 				@LockManager.getLock.calledWith(@project_id).should.equal true
 				done()
 
 		it "should release the lock", (done)->
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, (error)=>
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, (error) =>
 				@LockManager.releaseLock.calledWith(@project_id).should.equal true
 				done()
 
 		it "should error if it can't cat the lock", (done)->
 			@LockManager.getLock = sinon.stub().callsArgWith(1, "timed out")
-			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, (err)=>
-				expect(err).to.exist
-				err.should.equal "timed out"
-				done()			
-
-
+			@EditorController.deleteEntity @project_id, @entity_id, @type, @source, @user_id, (error) =>
+				expect(error).to.exist
+				error.should.equal "timed out"
+				done()
 
 	describe 'deleteEntityWithoutLock', ->
-		beforeEach ->
-			@ProjectEntityHandler.deleteEntity = (project_id, entity_id, type, callback)-> callback()
+		beforeEach (done) ->
 			@entity_id = "entity_id_here"
 			@type = "doc"
 			@EditorRealTimeController.emitToRoom = sinon.stub()
-
-		it 'should delete the folder using the project entity handler', (done)->
-			mock = sinon.mock(@ProjectEntityHandler).expects("deleteEntity").withArgs(@project_id, @entity_id, @type).callsArg(3)
-
-			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, ->
-				mock.verify()
-				done()
-
-		it 'notify users an entity has been deleted', (done)->
-			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, =>
-				@EditorRealTimeController.emitToRoom
-					.calledWith(@project_id, "removeEntity", @entity_id, @source)
-					.should.equal true
-				done()
+			@ProjectEntityHandler.deleteEntity = sinon.stub().callsArg(4)
+			@EditorController.deleteEntityWithoutLock @project_id, @entity_id, @type, @source, @user_id, done
+
+		it 'should delete the folder using the project entity handler', ->
+			@ProjectEntityHandler.deleteEntity
+				.calledWith(@project_id, @entity_id, @type, @user_id)
+				.should.equal.true
+
+		it 'notify users an entity has been deleted', ->
+			@EditorRealTimeController.emitToRoom
+				.calledWith(@project_id, "removeEntity", @entity_id, @source)
+				.should.equal true
 
 	describe "getting a list of project paths", ->
 

+ 2 - 2
services/web/test/unit/coffee/Editor/EditorHttpControllerTests.coffee

@@ -331,12 +331,12 @@ describe "EditorHttpController", ->
 				Project_id: @project_id
 				entity_id: @entity_id = "entity-id-123"
 				entity_type: @entity_type = "entity-type"
-			@EditorController.deleteEntity = sinon.stub().callsArg(4)
+			@EditorController.deleteEntity = sinon.stub().callsArg(5)
 			@EditorHttpController.deleteEntity @req, @res
 
 		it "should call EditorController.deleteEntity", ->
 			@EditorController.deleteEntity
-				.calledWith(@project_id, @entity_id, @entity_type, "editor")
+				.calledWith(@project_id, @entity_id, @entity_type, "editor", @userId)
 				.should.equal true
 
 		it "should send back a success response", ->

+ 48 - 151
services/web/test/unit/coffee/History/HistoryControllerTests.coffee

@@ -31,7 +31,7 @@ describe "HistoryController", ->
 
 		describe "for a project with project history", ->
 			beforeEach ->
-				@ProjectDetailsHandler.getDetails = sinon.stub().callsArgWith(1, null, {overleaf:{history:{display:true}}})
+				@ProjectDetailsHandler.getDetails = sinon.stub().callsArgWith(1, null, {overleaf:{history:{id: 42, display:true}}})
 				@HistoryController.selectHistoryApi @req, @res, @next
 
 			it "should set the flag for project history to true", ->
@@ -57,93 +57,55 @@ describe "HistoryController", ->
 				on: (event, handler) -> @events[event] = handler
 			@request.returns @proxy
 
-		describe "with project history enabled", ->
+		describe "for a project with the project history flag", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = true
-
-			describe "for a project with the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = true
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should get the user id", ->
-					@AuthenticationController.getLoggedInUserId
-						.calledWith(@req)
-						.should.equal true
-
-				it "should call the project history api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.project_history.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-				it "should pipe the response to the client", ->
-					@proxy.pipe
-						.calledWith(@res)
-						.should.equal true
-
-			describe "for a project without the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = false
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should get the user id", ->
-					@AuthenticationController.getLoggedInUserId
-						.calledWith(@req)
-						.should.equal true
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-				it "should pipe the response to the client", ->
-					@proxy.pipe
-						.calledWith(@res)
-						.should.equal true
-
-		describe "with project history disabled", ->
+				@req.useProjectHistory = true
+				@HistoryController.proxyToHistoryApi @req, @res, @next
+
+			it "should get the user id", ->
+				@AuthenticationController.getLoggedInUserId
+					.calledWith(@req)
+					.should.equal true
+
+			it "should call the project history api", ->
+				@request
+					.calledWith({
+						url: "#{@settings.apis.project_history.url}#{@req.url}"
+						method: @req.method
+						headers:
+							"X-User-Id": @user_id
+					})
+					.should.equal true
+
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
+
+		describe "for a project without the project history flag", ->
 			beforeEach ->
-				@settings.apis.project_history.enabled = false
-
-			describe "for a project with the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = true
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
-
-			describe "for a project without the project history flag", ->
-				beforeEach ->
-					@req.useProjectHistory = false
-					@HistoryController.proxyToHistoryApi @req, @res, @next
-
-				it "should call the track changes api", ->
-					@request
-						.calledWith({
-							url: "#{@settings.apis.trackchanges.url}#{@req.url}"
-							method: @req.method
-							headers:
-								"X-User-Id": @user_id
-						})
-						.should.equal true
+				@req.useProjectHistory = false
+				@HistoryController.proxyToHistoryApi @req, @res, @next
+
+			it "should get the user id", ->
+				@AuthenticationController.getLoggedInUserId
+					.calledWith(@req)
+					.should.equal true
+
+			it "should call the track changes api", ->
+				@request
+					.calledWith({
+						url: "#{@settings.apis.trackchanges.url}#{@req.url}"
+						method: @req.method
+						headers:
+							"X-User-Id": @user_id
+					})
+					.should.equal true
+
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
 
 		describe "with an error", ->
 			beforeEach ->
@@ -152,68 +114,3 @@ describe "HistoryController", ->
 
 			it "should pass the error up the call chain", ->
 				@next.calledWith(@error).should.equal true
-
-	describe "initializeProject", ->
-		describe "with project history enabled", ->
-			beforeEach ->
-				@settings.apis.project_history.enabled = true
-
-			describe "project history returns a successful response", ->
-				beforeEach ->
-					@overleaf_id = 1234
-					@res = statusCode: 200
-					@body = JSON.stringify(project: id: @overleaf_id)
-					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
-
-					@HistoryController.initializeProject @callback
-
-				it "should call the project history api", ->
-					@request.post.calledWith(
-						url: "#{@settings.apis.project_history.url}/project"
-					).should.equal true
-
-				it "should return the callback with the overleaf id", ->
-					@callback.calledWithExactly(null, { @overleaf_id }).should.equal true
-
-			describe "project history returns a response without the project id", ->
-				beforeEach ->
-					@res = statusCode: 200
-					@body = JSON.stringify(project: {})
-					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with an error", ->
-					@callback
-						.calledWith(sinon.match.has("message", "project-history did not provide an id"))
-						.should.equal true
-
-			describe "project history returns a unsuccessful response", ->
-				beforeEach ->
-					@res = statusCode: 404
-					@request.post = sinon.stub().callsArgWith(1, null, @res)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with an error", ->
-					@callback
-						.calledWith(sinon.match.has("message", "project-history returned a non-success status code: 404"))
-						.should.equal true
-
-			describe "project history errors", ->
-				beforeEach ->
-					@error = sinon.stub()
-					@request.post = sinon.stub().callsArgWith(1, @error)
-
-					@HistoryController.initializeProject @callback
-
-				it "should return the callback with the error", ->
-					@callback.calledWithExactly(@error).should.equal true
-
-		describe "with project history disabled", ->
-			beforeEach ->
-				@settings.apis.project_history.enabled = false
-				@HistoryController.initializeProject @callback
-
-			it "should return the callback", ->
-				@callback.calledWithExactly().should.equal true

+ 86 - 0
services/web/test/unit/coffee/History/HistoryManagerTests.coffee

@@ -0,0 +1,86 @@
+chai = require('chai')
+chai.should()
+sinon = require("sinon")
+modulePath = "../../../../app/js/Features/History/HistoryManager"
+SandboxedModule = require('sandboxed-module')
+
+describe "HistoryManager", ->
+	beforeEach ->
+		@callback = sinon.stub()
+		@user_id = "user-id-123"
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub().returns(@user_id)
+		@HistoryManager = SandboxedModule.require modulePath, requires:
+			"request" : @request = sinon.stub()
+			"settings-sharelatex": @settings = {}
+		@settings.apis =
+			trackchanges:
+				enabled: false
+				url: "http://trackchanges.example.com"
+			project_history:
+				url: "http://project_history.example.com"
+
+	describe "initializeProject", ->
+		describe "with project history enabled", ->
+			beforeEach ->
+				@settings.apis.project_history.initializeHistoryForNewProjects = true
+
+			describe "project history returns a successful response", ->
+				beforeEach ->
+					@overleaf_id = 1234
+					@res = statusCode: 200
+					@body = JSON.stringify(project: id: @overleaf_id)
+					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should call the project history api", ->
+					@request.post.calledWith(
+						url: "#{@settings.apis.project_history.url}/project"
+					).should.equal true
+
+				it "should return the callback with the overleaf id", ->
+					@callback.calledWithExactly(null, { @overleaf_id }).should.equal true
+
+			describe "project history returns a response without the project id", ->
+				beforeEach ->
+					@res = statusCode: 200
+					@body = JSON.stringify(project: {})
+					@request.post = sinon.stub().callsArgWith(1, null, @res, @body)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with an error", ->
+					@callback
+						.calledWith(sinon.match.has("message", "project-history did not provide an id"))
+						.should.equal true
+
+			describe "project history returns a unsuccessful response", ->
+				beforeEach ->
+					@res = statusCode: 404
+					@request.post = sinon.stub().callsArgWith(1, null, @res)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with an error", ->
+					@callback
+						.calledWith(sinon.match.has("message", "project-history returned a non-success status code: 404"))
+						.should.equal true
+
+			describe "project history errors", ->
+				beforeEach ->
+					@error = sinon.stub()
+					@request.post = sinon.stub().callsArgWith(1, @error)
+
+					@HistoryManager.initializeProject @callback
+
+				it "should return the callback with the error", ->
+					@callback.calledWithExactly(@error).should.equal true
+
+		describe "with project history disabled", ->
+			beforeEach ->
+				@settings.apis.project_history.initializeHistoryForNewProjects = false
+				@HistoryManager.initializeProject @callback
+
+			it "should return the callback", ->
+				@callback.calledWithExactly().should.equal true

+ 4 - 4
services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee

@@ -38,7 +38,7 @@ describe 'ProjectCreationHandler', ->
 			setRootDoc: sinon.stub().callsArg(2)
 		@ProjectDetailsHandler =
 			validateProjectName: sinon.stub().yields()
-		@HistoryController =
+		@HistoryManager =
 			initializeProject: sinon.stub().callsArg(0)
 
 		@user =
@@ -53,7 +53,7 @@ describe 'ProjectCreationHandler', ->
 			'../../models/User': User:@User
 			'../../models/Project':{Project:@ProjectModel}
 			'../../models/Folder':{Folder:@FolderModel}
-			'../History/HistoryController': @HistoryController
+			'../History/HistoryManager': @HistoryManager
 			'./ProjectEntityHandler':@ProjectEntityHandler
 			"./ProjectDetailsHandler":@ProjectDetailsHandler
 			"settings-sharelatex": @Settings = {}
@@ -66,7 +66,7 @@ describe 'ProjectCreationHandler', ->
 	describe 'Creating a Blank project', ->
 		beforeEach ->
 			@overleaf_id = 1234
-			@HistoryController.initializeProject = sinon.stub().callsArgWith(0, null, { @overleaf_id })
+			@HistoryManager.initializeProject = sinon.stub().callsArgWith(0, null, { @overleaf_id })
 			@ProjectModel::save = sinon.stub().callsArg(0)
 
 		describe "successfully", ->
@@ -83,7 +83,7 @@ describe 'ProjectCreationHandler', ->
 
 			it "should initialize the project overleaf if history id not provided", (done)->
 				@handler.createBlankProject ownerId, projectName, done
-				@HistoryController.initializeProject.calledWith().should.equal true
+				@HistoryManager.initializeProject.calledWith().should.equal true
 
 			it "should set the overleaf id if overleaf id not provided", (done)->
 				@handler.createBlankProject ownerId, projectName, (err, project)=>

+ 6 - 6
services/web/test/unit/coffee/Project/ProjectDuplicatorTests.coffee

@@ -64,7 +64,7 @@ describe 'ProjectDuplicator', ->
 		@projectOptionsHandler =
 			setCompiler : sinon.stub()								
 		@entityHandler =
-			addDocWithProject: sinon.stub().callsArgWith(5, null, {name:"somDoc"})
+			addDoc: sinon.stub().callsArgWith(5, null, {name:"somDoc"})
 			copyFileFromExistingProjectWithProject: sinon.stub().callsArgWith(5)
 			setRootDoc: sinon.stub()
 			addFolderWithProject: sinon.stub().callsArgWith(3, null, @newFolder)
@@ -112,13 +112,13 @@ describe 'ProjectDuplicator', ->
 			done()
 
 	it 'should use the same compiler', (done)->
-		@entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
+		@entityHandler.addDoc.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true
 			done()
 	
 	it 'should use the same root doc', (done)->
-		@entityHandler.addDocWithProject.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
+		@entityHandler.addDoc.callsArgWith(5, null, @rootFolder.docs[0], @owner._id)
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true
 			done()
@@ -139,13 +139,13 @@ describe 'ProjectDuplicator', ->
 	it 'should copy all the docs', (done)->
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@DocstoreManager.getAllDocs.calledWith(@old_project_id).should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @stubbedNewProject.rootFolder[0]._id, @doc0.name, @doc0_lines, @owner._id)
 				.should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @newFolder._id, @doc1.name, @doc1_lines, @owner._id)
 				.should.equal true
-			@entityHandler.addDocWithProject
+			@entityHandler.addDoc
 				.calledWith(@stubbedNewProject, @newFolder._id, @doc2.name, @doc2_lines, @owner._id)
 				.should.equal true
 			done()

+ 98 - 20
services/web/test/unit/coffee/Project/ProjectEntityHandlerTests.coffee

@@ -157,13 +157,13 @@ describe 'ProjectEntityHandler', ->
 			@ProjectGetter.getProject.callsArgWith(2, null, @project)
 			@tpdsUpdateSender.deleteEntity = sinon.stub().callsArg(1)
 			@ProjectEntityHandler._removeElementFromMongoArray = sinon.stub().callsArg(3)
-			@ProjectEntityHandler._cleanUpEntity = sinon.stub().callsArg(3)
+			@ProjectEntityHandler._cleanUpEntity = sinon.stub().callsArg(5)
 			@path = mongo: "mongo.path", fileSystem: "/file/system/path"
 			@projectLocator.findElement = sinon.stub().callsArgWith(1, null, @entity = { _id: entity_id }, @path)
 
 		describe "deleting from Mongo", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler.deleteEntity project_id, entity_id, @type = 'file', done
+				@ProjectEntityHandler.deleteEntity project_id, entity_id, @type = 'file', userId, done
 
 			it "should retreive the path", ->
 				@projectLocator.findElement.called.should.equal true
@@ -182,7 +182,7 @@ describe 'ProjectEntityHandler', ->
 
 			it "should clean up the entity from the rest of the system", ->
 				@ProjectEntityHandler._cleanUpEntity
-					.calledWith(@project, @entity, @type)
+					.calledWith(@project, @entity, @type, @path.fileSystem, userId)
 					.should.equal true
 
 	describe "_cleanUpEntity", ->
@@ -193,7 +193,9 @@ describe 'ProjectEntityHandler', ->
 
 		describe "a file", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler._cleanUpEntity @project, _id: @entity_id, 'file', done
+				@path = "/file/system/path.png"
+				@entity = _id: @entity_id
+				@ProjectEntityHandler._cleanUpEntity @project, @entity, 'file', @path, userId, done
 
 			it "should delete the file from FileStoreHandler", ->
 				@FileStoreHandler.deleteFile.calledWith(project_id, @entity_id).should.equal true
@@ -201,38 +203,56 @@ describe 'ProjectEntityHandler', ->
 			it "should not attempt to delete from the document updater", ->
 				@documentUpdaterHandler.deleteDoc.called.should.equal false
 
+			it "should should send the update to the doc updater", ->
+				oldFiles = [ file: @entity, path: @path ]
+				@documentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, userId, {oldFiles})
+					.should.equal true
+
 		describe "a doc", ->
 			beforeEach (done) ->
-				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpEntity @project, @entity = {_id: @entity_id}, 'doc', done
+				@path = "/file/system/path.tex"
+				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(4)
+				@entity = {_id: @entity_id}
+				@ProjectEntityHandler._cleanUpEntity @project, @entity, 'doc', @path, userId, done
 
 			it "should clean up the doc", ->
 				@ProjectEntityHandler._cleanUpDoc
-					.calledWith(@project, @entity)
+					.calledWith(@project, @entity, @path, userId)
 					.should.equal true
 
 		describe "a folder", ->
 			beforeEach (done) ->
 				@folder =
 					folders: [
-						fileRefs: [ @file1 = {_id: "file-id-1" } ]
-						docs:     [ @doc1  = { _id: "doc-id-1" } ]
+						name: "subfolder"
+						fileRefs: [ @file1 = { _id: "file-id-1", name: "file-name-1"} ]
+						docs:     [ @doc1  = { _id: "doc-id-1", name: "doc-name-1" } ]
 						folders:  []
 					]
-					fileRefs: [ @file2 = { _id: "file-id-2" } ]
-					docs:     [ @doc2  = { _id: "doc-id-2" } ]
+					fileRefs: [ @file2 = { _id: "file-id-2", name: "file-name-2" } ]
+					docs:     [ @doc2  = { _id: "doc-id-2", name: "doc-name-2" } ]
 
-				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpFile = sinon.stub().callsArg(2)
-				@ProjectEntityHandler._cleanUpEntity @project, @folder, "folder", done
+				@ProjectEntityHandler._cleanUpDoc = sinon.stub().callsArg(4)
+				@ProjectEntityHandler._cleanUpFile = sinon.stub().callsArg(4)
+				path = "/folder"
+				@ProjectEntityHandler._cleanUpEntity @project, @folder, "folder", path, userId, done
 
 			it "should clean up all sub files", ->
-				@ProjectEntityHandler._cleanUpFile.calledWith(@project, @file1).should.equal true
-				@ProjectEntityHandler._cleanUpFile.calledWith(@project, @file2).should.equal true
+				@ProjectEntityHandler._cleanUpFile
+					.calledWith(@project, @file1, "/folder/subfolder/file-name-1", userId)
+					.should.equal true
+				@ProjectEntityHandler._cleanUpFile
+					.calledWith(@project, @file2, "/folder/file-name-2", userId)
+					.should.equal true
 
 			it "should clean up all sub docs", ->
-				@ProjectEntityHandler._cleanUpDoc.calledWith(@project, @doc1).should.equal true
-				@ProjectEntityHandler._cleanUpDoc.calledWith(@project, @doc2).should.equal true
+				@ProjectEntityHandler._cleanUpDoc
+					.calledWith(@project, @doc1, "/folder/subfolder/doc-name-1", userId)
+					.should.equal true
+				@ProjectEntityHandler._cleanUpDoc
+					.calledWith(@project, @doc2, "/folder/doc-name-2", userId)
+					.should.equal true
 
 	describe 'moveEntity', ->
 		beforeEach ->
@@ -496,6 +516,51 @@ describe 'ProjectEntityHandler', ->
 				.calledWith(project_id, userId, {newDocs})
 				.should.equal true
 
+	describe 'addDocWithoutUpdatingHistory', ->
+		beforeEach ->
+			@name = "some new doc"
+			@lines = ['1234','abc']
+			@path = "/path/to/doc"
+
+			@ProjectEntityHandler._putElement = sinon.stub().callsArgWith(4, null, {path:{fileSystem:@path}})
+			@callback = sinon.stub()
+			@tpdsUpdateSender.addDoc = sinon.stub().callsArg(1)
+			@DocstoreManager.updateDoc = sinon.stub().yields(null, true, 0)
+
+			@ProjectEntityHandler.addDocWithoutUpdatingHistory project_id, folder_id, @name, @lines, userId, @callback
+
+			# Created doc
+			@doc = @ProjectEntityHandler._putElement.args[0][2]
+			@doc.name.should.equal @name
+			expect(@doc.lines).to.be.undefined
+
+		it 'should call put element', ->
+			@ProjectEntityHandler._putElement.calledWith(@project, folder_id, @doc).should.equal true
+
+		it 'should return doc and parent folder', ->
+			@callback.calledWith(null, @doc, folder_id).should.equal true
+
+		it 'should call third party data store', ->
+			@tpdsUpdateSender.addDoc
+				.calledWith({
+					project_id: project_id
+					doc_id: doc_id
+					path: @path
+					project_name: @project.name
+					rev: 0
+				})
+				.should.equal true
+
+		it "should send the doc lines to the doc store", ->
+			@DocstoreManager.updateDoc
+				.calledWith(project_id, @doc._id.toString(), @lines)
+				.should.equal true
+
+		it "should not should send the change in project structure to the doc updater", () ->
+			@documentUpdaterHandler.updateProjectStructure
+				.called
+				.should.equal false
+
 	describe "restoreDoc", ->
 		beforeEach ->
 			@name = "doc-name"
@@ -584,6 +649,12 @@ describe 'ProjectEntityHandler', ->
 
 			@ProjectEntityHandler.addFile project_id, folder_id, fileName, {}, userId, () ->
 
+		it "should not send the change in project structure to the doc updater when called as addFileWithoutUpdatingHistory", (done) ->
+			@documentUpdaterHandler.updateProjectStructure = sinon.stub().yields()
+			@ProjectEntityHandler.addFileWithoutUpdatingHistory project_id, folder_id, fileName, {}, userId, () =>
+				@documentUpdaterHandler.updateProjectStructure.called.should.equal false
+				done()
+
 	describe 'replaceFile', ->
 		beforeEach ->
 			@projectLocator
@@ -1116,6 +1187,7 @@ describe 'ProjectEntityHandler', ->
 			@doc =
 				_id: ObjectId()
 				name: "test.tex"
+			@path = "/path/to/doc"
 			@ProjectEntityHandler.unsetRootDoc = sinon.stub().callsArg(1)
 			@ProjectEntityHandler._insertDeletedDocReference = sinon.stub().callsArg(2)
 			@documentUpdaterHandler.deleteDoc = sinon.stub().callsArg(2)
@@ -1125,7 +1197,7 @@ describe 'ProjectEntityHandler', ->
 		describe "when the doc is the root doc", ->
 			beforeEach ->
 				@project.rootDoc_id = @doc._id
-				@ProjectEntityHandler._cleanUpDoc @project, @doc, @callback
+				@ProjectEntityHandler._cleanUpDoc @project, @doc, @path, userId, @callback
 
 			it "should unset the root doc", ->
 				@ProjectEntityHandler.unsetRootDoc
@@ -1146,13 +1218,19 @@ describe 'ProjectEntityHandler', ->
 					.calledWith(project_id, @doc._id.toString())
 					.should.equal true
 
+			it "should should send the update to the doc updater", ->
+				oldDocs = [ doc: @doc, path: @path ]
+				@documentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, userId, {oldDocs})
+					.should.equal true
+
 			it "should call the callback", ->
 				@callback.called.should.equal true
 
 		describe "when the doc is not the root doc", ->
 			beforeEach ->
 				@project.rootDoc_id = ObjectId()
-				@ProjectEntityHandler._cleanUpDoc @project, @doc, @callback
+				@ProjectEntityHandler._cleanUpDoc @project, @doc, @path, userId, @callback
 
 			it "should not unset the root doc", ->
 				@ProjectEntityHandler.unsetRootDoc.called.should.equal false

+ 5 - 3
services/web/test/unit/coffee/ThirdPartyDataStore/TpdsUpdateHandlerTests.coffee

@@ -8,7 +8,7 @@ describe 'TpdsUpdateHandler', ->
 	beforeEach ->
 		@requestQueuer = {}
 		@updateMerger = 
-			deleteUpdate: (user_id, path, source, cb)->cb()
+			deleteUpdate: (user_id, project_id, path, source, cb)->cb()
 			mergeUpdate:(user_id, project_id, path, update, source, cb)->cb()
 		@editorController = {}
 		@project_id = "dsjajilknaksdn"
@@ -107,11 +107,13 @@ describe 'TpdsUpdateHandler', ->
 		it 'should call deleteEntity in the collaberation manager', (done)->
 			path = "/delete/this"
 			update = {}
-			@updateMerger.deleteUpdate = sinon.stub().callsArg(3)
+			@updateMerger.deleteUpdate = sinon.stub().callsArg(4)
 
 			@handler.deleteUpdate @user_id, @project.name, path, @source, =>
 				@projectDeleter.markAsDeletedByExternalSource.calledWith(@project._id).should.equal false
-				@updateMerger.deleteUpdate.calledWith(@project_id, path, @source).should.equal true
+				@updateMerger.deleteUpdate
+					.calledWith(@user_id, @project_id, path, @source)
+					.should.equal true
 				done()
 
 		it 'should mark the project as deleted by external source if path is a single slash', (done)->

+ 3 - 3
services/web/test/unit/coffee/ThirdPartyDataStore/UpdateMergerTests.coffee

@@ -145,13 +145,13 @@ describe 'UpdateMerger :', ->
 
 		it 'should get the element id', ->
 			@projectLocator.findElementByPath = sinon.spy()
-			@updateMerger.deleteUpdate @project_id, @path, @source, ->
+			@updateMerger.deleteUpdate @user_id, @project_id, @path, @source, ->
 			@projectLocator.findElementByPath.calledWith(@project_id, @path).should.equal true
 
 		it 'should delete the entity in the editor controller with the correct type', (done)->
 			@entity.lines = []
-			mock = sinon.mock(@editorController).expects("deleteEntity").withArgs(@project_id, @entity_id, @type, @source).callsArg(4)
-			@updateMerger.deleteUpdate @project_id, @path, @source, ->
+			mock = sinon.mock(@editorController).expects("deleteEntity").withArgs(@project_id, @entity_id, @type, @source, @user_id).callsArg(5)
+			@updateMerger.deleteUpdate @user_id, @project_id, @path, @source, ->
 				mock.verify()
 				done()
 

+ 134 - 0
services/web/test/unit_frontend/coffee/HistoryManagerV2Tests.coffee

@@ -0,0 +1,134 @@
+Path = require 'path'
+SandboxedModule = require "sandboxed-module"
+modulePath = Path.join __dirname, '../../../public/js/ide/history/HistoryV2Manager'
+sinon = require("sinon")
+expect = require("chai").expect
+
+describe "HistoryV2Manager", ->
+	beforeEach ->
+		@moment = {}
+		@ColorManager = {}
+		SandboxedModule.require modulePath, globals:
+			"define": (dependencies, builder) =>
+				@HistoryV2Manager = builder(@moment, @ColorManager)
+
+		@scope =
+			$watch: sinon.stub()
+			$on: sinon.stub()
+		@ide = {}
+
+		@historyManager = new @HistoryV2Manager(@ide, @scope)
+
+	it "should setup the history scope on intialization", ->
+		expect(@scope.history).to.deep.equal({
+			isV2: true
+			updates: []
+			nextBeforeTimestamp: null
+			atEnd: false
+			selection: {
+				updates: []
+				pathname: null
+				range: {
+					fromV: null
+					toV: null
+				}
+			}
+			diff: null
+		})
+
+	describe "_perDocSummaryOfUpdates", ->
+		it "should return the range of updates for the docs", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				pathnames: ["main.tex"]
+				fromV: 7, toV: 9
+			},{
+				pathnames: ["main.tex", "foo.tex"]
+				fromV: 4, toV: 6
+			},{
+				pathnames: ["main.tex"]
+				fromV: 3, toV: 3
+			},{
+				pathnames: ["foo.tex"]
+				fromV: 0, toV: 2
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 3, toV: 9 },
+				"foo.tex": { fromV: 0, toV: 6 }
+			})
+
+		it "should track renames", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				pathnames: ["main2.tex"]
+				fromV: 5, toV: 9
+			},{
+				project_ops: [{
+					rename: {
+						pathname: "main1.tex",
+						newPathname: "main2.tex"
+					}
+				}],
+				fromV: 4, toV: 4
+			},{
+				pathnames: ["main1.tex"]
+				fromV: 3, toV: 3
+			},{
+				project_ops: [{
+					rename: {
+						pathname: "main0.tex",
+						newPathname: "main1.tex"
+					}
+				}],
+				fromV: 2, toV: 2
+			},{
+				pathnames: ["main0.tex"]
+				fromV: 0, toV: 1
+			}])
+
+			expect(result).to.deep.equal({
+				"main0.tex": { fromV: 0, toV: 9 }
+			})
+
+		it "should track single renames", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					rename: {
+						pathname: "main1.tex",
+						newPathname: "main2.tex"
+					}
+				}],
+				fromV: 4, toV: 5
+			}])
+
+			expect(result).to.deep.equal({
+				"main1.tex": { fromV: 4, toV: 5 }
+			})
+
+		it "should track additions", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					add:
+						pathname: "main.tex"
+				}]
+				fromV: 0, toV: 1
+			}, {
+				pathnames: ["main.tex"]
+				fromV: 1, toV: 4
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 0, toV: 4 }
+			})
+
+		it "should track single additions", ->
+			result = @historyManager._perDocSummaryOfUpdates([{
+				project_ops: [{
+					add:
+						pathname: "main.tex"
+				}]
+				fromV: 0, toV: 1
+			}])
+
+			expect(result).to.deep.equal({
+				"main.tex": { fromV: 0, toV: 1 }
+			})