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

Merge branch 'master' into sk-fix-references-full-index

Shane Kilkelly 8 лет назад
Родитель
Сommit
06c0b45ef7
25 измененных файлов с 323 добавлено и 79 удалено
  1. 0 1
      services/web/README.md
  2. 8 0
      services/web/app/coffee/Features/Errors/Errors.coffee
  3. 3 0
      services/web/app/coffee/Features/Project/ProjectController.coffee
  4. 16 12
      services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee
  5. 34 18
      services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
  6. 5 0
      services/web/app/coffee/Features/User/UserController.coffee
  7. 5 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  8. 2 2
      services/web/app/coffee/infrastructure/Features.coffee
  9. 40 11
      services/web/app/coffee/infrastructure/LockManager.coffee
  10. 10 8
      services/web/app/coffee/models/User.coffee
  11. 4 1
      services/web/app/views/project/editor.pug
  12. 3 0
      services/web/app/views/project/editor/editor.pug
  13. 20 0
      services/web/app/views/project/editor/left-menu.pug
  14. 4 2
      services/web/app/views/project/list/project-list.pug
  15. 1 1
      services/web/app/views/project/list/side-bar.pug
  16. 2 0
      services/web/public/coffee/ide.coffee
  17. 45 7
      services/web/public/coffee/ide/editor/directives/aceEditor.coffee
  18. 14 0
      services/web/public/coffee/ide/settings/controllers/SettingsController.coffee
  19. 3 0
      services/web/public/coffee/main/project-list/project-list.coffee
  20. 71 0
      services/web/public/js/ace-1.2.5/theme-overleaf.js
  21. 5 2
      services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee
  22. 5 1
      services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee
  23. 13 6
      services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
  24. 8 6
      services/web/test/unit/coffee/infrastructure/LockManager/ReleasingTheLock.coffee
  25. 2 1
      services/web/test/unit/coffee/infrastructure/LockManager/tryLockTests.coffee

+ 0 - 1
services/web/README.md

@@ -43,7 +43,6 @@ Unit tests can be run in the `test_unit` container defined in `docker-compose.te
 The makefile contains a short cut to run these:
 
 ```
-make install # Only needs running once, or when npm packages are updated
 make unit_test
 ```
 

+ 8 - 0
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -47,6 +47,13 @@ UnsupportedExportRecordsError = (message) ->
 	return error
 UnsupportedExportRecordsError.prototype.__proto___ = Error.prototype
 
+V1HistoryNotSyncedError = (message) ->
+	error = new Error(message)
+	error.name = "V1HistoryNotSyncedError"
+	error.__proto__ = V1HistoryNotSyncedError.prototype
+	return error
+V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype
+
 ProjectHistoryDisabledError = (message) ->
 	error = new Error(message)
 	error.name = "ProjectHistoryDisabledError "
@@ -62,4 +69,5 @@ module.exports = Errors =
 	UnsupportedFileTypeError: UnsupportedFileTypeError
 	UnsupportedBrandError: UnsupportedBrandError
 	UnsupportedExportRecordsError: UnsupportedExportRecordsError
+	V1HistoryNotSyncedError: V1HistoryNotSyncedError
 	ProjectHistoryDisabledError: ProjectHistoryDisabledError

+ 3 - 0
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -299,6 +299,8 @@ module.exports = ProjectController =
 						autoPairDelimiters: user.ace.autoPairDelimiters
 						pdfViewer : user.ace.pdfViewer
 						syntaxValidation: user.ace.syntaxValidation
+						fontFamily: user.ace.fontFamily
+						lineHeight: user.ace.lineHeight
 					}
 					trackChangesState: project.track_changes
 					privilegeLevel: privilegeLevel
@@ -311,6 +313,7 @@ module.exports = ProjectController =
 					maxDocLength: Settings.max_doc_length
 					useV2History: !!project.overleaf?.history?.display
 					showRichText: req.query?.rt == 'true'
+					showPublishModal: req.query?.pm == 'true'
 				timer.done()
 
 	_buildProjectList: (allProjects, v1Projects = [])->

+ 16 - 12
services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee

@@ -16,38 +16,42 @@ AnalyticsManger = require("../Analytics/AnalyticsManager")
 
 module.exports = ProjectCreationHandler =
 
-	createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)->
+	createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)->
 		metrics.inc("project-creation")
 		if arguments.length == 3
-			callback = projectHistoryId
-			projectHistoryId = null
+			callback = attributes
+			attributes = null
 
 		ProjectDetailsHandler.validateProjectName projectName, (error) ->
 			return callback(error) if error?
 			logger.log owner_id:owner_id, projectName:projectName, "creating blank project"
-			if projectHistoryId?
-				ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, (error, project) ->
+			if attributes?
+				ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) ->
 					return callback(error) if error?
 					AnalyticsManger.recordEvent(
-						owner_id, 'project-imported', { projectId: project._id, projectHistoryId: projectHistoryId }
+						owner_id, 'project-imported', { projectId: project._id, attributes: attributes }
 					)
 					callback(error, project)
 			else
 				HistoryManager.initializeProject (error, history) ->
 					return callback(error) if error?
-					ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, (error, project) ->
+					attributes = overleaf: history: id: history?.overleaf_id
+					ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) ->
 						return callback(error) if error?
 						AnalyticsManger.recordEvent(
 							owner_id, 'project-created', { projectId: project._id }
 						)
 						callback(error, project)
 
-	_createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)->
+	_createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)->
 		rootFolder = new Folder {'name':'rootFolder'}
-		project = new Project
-			 owner_ref          : new ObjectId(owner_id)
-			 name               : projectName
-		project.overleaf.history.id = projectHistoryId
+
+		attributes.owner_ref = new ObjectId(owner_id)
+		attributes.name = projectName
+		project = new Project attributes
+
+		Object.assign(project, attributes)
+
 		if Settings.apis?.project_history?.displayHistoryForNewProjects
 			project.overleaf.history.display = true
 		if Settings.currentImageName?

+ 34 - 18
services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee

@@ -405,14 +405,41 @@ module.exports = ProjectEntityUpdateHandler = self =
 				DocumentUpdaterHandler.resyncProjectHistory project_id, projectHistoryId, docs, files, callback
 
 	_cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) ->
+		self._updateProjectStructureWithDeletedEntity project, entity, entityType, path, userId, (error) ->
+			return callback(error) if error?
+			if(entityType.indexOf("file") != -1)
+				self._cleanUpFile project, entity, path, userId, callback
+			else if (entityType.indexOf("doc") != -1)
+				self._cleanUpDoc project, entity, path, userId, callback
+			else if (entityType.indexOf("folder") != -1)
+				self._cleanUpFolder project, entity, path, userId, callback
+			else
+				callback()
+
+	# Note: the _cleanUpEntity code and _updateProjectStructureWithDeletedEntity
+	# methods both need to recursively iterate over the entities in folder.
+	# These are currently using separate implementations of the recursion. In
+	# future, these could be simplified using a common project entity iterator.
+	_updateProjectStructureWithDeletedEntity: (project, entity, entityType, entityPath, userId, callback = (error) ->) ->
+		# compute the changes to the project structure
 		if(entityType.indexOf("file") != -1)
-			self._cleanUpFile project, entity, path, userId, callback
+			changes = oldFiles: [ {file: entity, path: entityPath} ]
 		else if (entityType.indexOf("doc") != -1)
-			self._cleanUpDoc project, entity, path, userId, callback
+			changes = oldDocs: [ {doc: entity, path: entityPath} ]
 		else if (entityType.indexOf("folder") != -1)
-			self._cleanUpFolder project, entity, path, userId, callback
-		else
-			callback()
+			changes = {oldDocs: [], oldFiles: []}
+			_recurseFolder = (folder, folderPath) ->
+				for doc in folder.docs
+					changes.oldDocs.push {doc, path: path.join(folderPath, doc.name)}
+				for file in folder.fileRefs
+					changes.oldFiles.push {file, path: path.join(folderPath, file.name)}
+				for childFolder in folder.folders
+					_recurseFolder(childFolder, path.join(folderPath, childFolder.name))
+			_recurseFolder entity, entityPath
+		# now send the project structure changes to the docupdater
+		project_id = project._id.toString()
+		projectHistoryId = project.overleaf?.history?.id
+		DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
 
 	_cleanUpDoc: (project, doc, path, userId, callback = (error) ->) ->
 		project_id = project._id.toString()
@@ -429,21 +456,10 @@ module.exports = ProjectEntityUpdateHandler = self =
 				return callback(error) if error?
 				DocumentUpdaterHandler.deleteDoc project_id, doc_id, (error) ->
 					return callback(error) if error?
-					DocstoreManager.deleteDoc project_id, doc_id, (error) ->
-						return callback(error) if error?
-						changes = oldDocs: [ {doc, path} ]
-						projectHistoryId = project.overleaf?.history?.id
-						DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
+					DocstoreManager.deleteDoc project_id, doc_id, callback
 
 	_cleanUpFile: (project, file, path, userId, callback = (error) ->) ->
-		ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, (error) ->
-			return callback(error) if error?
-			project_id = project._id.toString()
-			projectHistoryId = project.overleaf?.history?.id
-			changes = oldFiles: [ {file, path} ]
-			# we are now keeping a copy of every file versio so we no longer delete
-			# the file from the filestore
-			DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback
+		ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, callback
 
 	_cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) ->
 		jobs = []

+ 5 - 0
services/web/app/coffee/Features/User/UserController.coffee

@@ -81,6 +81,11 @@ module.exports = UserController =
 				user.ace.pdfViewer = req.body.pdfViewer
 			if req.body.syntaxValidation?
 				user.ace.syntaxValidation = req.body.syntaxValidation
+			if req.body.fontFamily?
+				user.ace.fontFamily = req.body.fontFamily
+			if req.body.lineHeight?
+				user.ace.lineHeight = req.body.lineHeight
+
 			user.save (err)->
 				newEmail = req.body.email?.trim().toLowerCase()
 				if !newEmail? or newEmail == user.email or req.externalAuthenticationSystemUsed()

+ 5 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -183,6 +183,9 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 		# Don't include the query string parameters, otherwise Google
 		# treats ?nocdn=true as the canonical version
 		res.locals.currentUrl = Url.parse(req.originalUrl).pathname
+		res.locals.capitalize = (string) ->
+			return "" if string.length == 0
+			return string.charAt(0).toUpperCase() + string.slice(1)
 		next()
 
 	webRouter.use (req, res, next)->
@@ -321,5 +324,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 			chatMessageBorderLightness : if isOl then "40%" else "70%"
 			chatMessageBgSaturation    : if isOl then "85%" else "60%"
 			chatMessageBgLightness     : if isOl then "40%" else "97%"
+			defaultFontFamily          : if isOl then 'lucida' else 'monaco'
+			defaultLineHeight          : if isOl then 'normal' else 'compact'
 			renderAnnouncements        : !isOl
 		next()

+ 2 - 2
services/web/app/coffee/infrastructure/Features.coffee

@@ -14,9 +14,9 @@ module.exports = Features =
 				return Settings.enableGithubSync
 			when 'v1-return-message'
 				return Settings.accountMerge? and Settings.overleaf?
-			when 'publish-modal'
-				return Settings.showPublishModal
 			when 'custom-togglers'
 				return Settings.overleaf?
+			when 'templates'
+				return !Settings.overleaf?
 			else
 				throw new Error("unknown feature: #{feature}")

+ 40 - 11
services/web/app/coffee/infrastructure/LockManager.coffee

@@ -3,20 +3,37 @@ Settings = require('settings-sharelatex')
 RedisWrapper = require("./RedisWrapper")
 rclient = RedisWrapper.client("lock")
 logger = require "logger-sharelatex"
+os = require "os"
+crypto = require "crypto"
+
+HOST = os.hostname()
+PID = process.pid
+RND = crypto.randomBytes(4).toString('hex')
+COUNT = 0
 
 module.exports = LockManager =
 	LOCK_TEST_INTERVAL: 50 # 50ms between each test of the lock
+	MAX_TEST_INTERVAL: 1000 # back off to 1s between each test of the lock
 	MAX_LOCK_WAIT_TIME: 10000 # 10s maximum time to spend trying to get the lock
 	REDIS_LOCK_EXPIRY: 30 # seconds. Time until lock auto expires in redis
 	SLOW_EXECUTION_THRESHOLD: 5000 # 5s, if execution takes longer than this then log
 
+	# Use a signed lock value as described in
+	# http://redis.io/topics/distlock#correct-implementation-with-a-single-instance
+	# to prevent accidental unlocking by multiple processes
+	randomLock : () ->
+		time = Date.now()
+		return "locked:host=#{HOST}:pid=#{PID}:random=#{RND}:time=#{time}:count=#{COUNT++}"
+
+	unlockScript: 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'
+
 	runWithLock: (namespace, id, runner = ( (releaseLock = (error) ->) -> ), callback = ( (error) -> )) ->
 		# This error is defined here so we get a useful stacktrace
 		slowExecutionError = new Error "slow execution during lock"
 
 		timer = new metrics.Timer("lock.#{namespace}")
 		key = "lock:web:#{namespace}:#{id}"
-		LockManager._getLock key, namespace, (error) ->
+		LockManager._getLock key, namespace, (error, lockValue) ->
 			return callback(error) if error?
 
 			# The lock can expire in redis but the process carry on. This setTimout call
@@ -27,7 +44,7 @@ module.exports = LockManager =
 			exceededLockTimeout = setTimeout countIfExceededLockTimeout, LockManager.REDIS_LOCK_EXPIRY * 1000
 
 			runner (error1, values...) ->
-				LockManager._releaseLock key, (error2) ->
+				LockManager._releaseLock key, lockValue, (error2) ->
 					clearTimeout exceededLockTimeout
 
 					timeTaken = new Date - timer.start
@@ -39,19 +56,21 @@ module.exports = LockManager =
 					return callback(error) if error?
 					callback null, values...
 
-	_tryLock : (key, namespace, callback = (err, isFree)->)->
-		rclient.set key, "locked", "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
+	_tryLock : (key, namespace, callback = (err, isFree, lockValue)->)->
+		lockValue = LockManager.randomLock()
+		rclient.set key, lockValue, "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)->
 			return callback(err) if err?
 			if gotLock == "OK"
 				metrics.inc "lock.#{namespace}.try.success"
-				callback err, true
+				callback err, true, lockValue
 			else
 				metrics.inc "lock.#{namespace}.try.failed"
 				logger.log key: key, redis_response: gotLock, "lock is locked"
 				callback err, false
 
-	_getLock: (key, namespace, callback = (error) ->) ->
+	_getLock: (key, namespace, callback = (error, lockValue) ->) ->
 		startTime = Date.now()
+		testInterval = LockManager.LOCK_TEST_INTERVAL
 		attempts = 0
 		do attempt = () ->
 			if Date.now() - startTime > LockManager.MAX_LOCK_WAIT_TIME
@@ -59,13 +78,23 @@ module.exports = LockManager =
 				return callback(new Error("Timeout"))
 
 			attempts += 1
-			LockManager._tryLock key, namespace, (error, gotLock) ->
+			LockManager._tryLock key, namespace, (error, gotLock, lockValue) ->
 				return callback(error) if error?
 				if gotLock
 					metrics.gauge "lock.#{namespace}.get.success.tries", attempts
-					callback(null)
+					callback(null, lockValue)
 				else
-					setTimeout attempt, LockManager.LOCK_TEST_INTERVAL
+					setTimeout attempt, testInterval
+					# back off when the lock is taken to avoid overloading
+					testInterval = Math.min(testInterval * 2, LockManager.MAX_TEST_INTERVAL)
 
-	_releaseLock: (key, callback)->
-		rclient.del key, callback
+	_releaseLock: (key, lockValue, callback)->
+		rclient.eval LockManager.unlockScript, 1, key, lockValue, (err, result) ->
+			if err?
+				return callback(err)
+			else if result? and result isnt 1 # successful unlock should release exactly one key
+				logger.error {key:key, lockValue:lockValue, redis_err:err, redis_result:result}, "unlocking error"
+				metrics.inc "unlock-error"
+				return callback(new Error("tried to release timed out lock"))
+			else
+				callback(null,result)

+ 10 - 8
services/web/app/coffee/models/User.coffee

@@ -20,14 +20,16 @@ UserSchema = new Schema
 	loginCount        : {type : Number, default: 0}
 	holdingAccount    : {type : Boolean, default: false}
 	ace               : {
-		mode        :   {type : String, default: 'none'}
-		theme       :   {type : String, default: 'textmate'}
-		fontSize    :   {type : Number, default:'12'}
-		autoComplete:   {type : Boolean, default: true}
-		autoPairDelimiters: {type : Boolean, default: true}
-		spellCheckLanguage :   {type : String, default: "en"}
-		pdfViewer   :   {type : String, default: "pdfjs"}
-		syntaxValidation   :   {type : Boolean}
+		mode               : {type : String, default: 'none'}
+		theme              : {type : String, default: 'textmate'}
+		fontSize           : {type : Number, default:'12'}
+		autoComplete       : {type : Boolean, default: true}
+		autoPairDelimiters : {type : Boolean, default: true}
+		spellCheckLanguage : {type : String, default: "en"}
+		pdfViewer          : {type : String, default: "pdfjs"}
+		syntaxValidation   : {type : Boolean}
+		fontFamily         : {type : String}
+		lineHeight         : {type : String}
 	}
 	features		  : {
 		collaborators:    { type:Number,  default: Settings.defaultFeatures.collaborators }

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

@@ -154,7 +154,10 @@ block requirejs
 				},
 				"ace/ext-language_tools": {
 					"deps": ["ace/ace"]
-				}
+				},
+				"ace/keybinding-vim": {
+					"deps": ["ace/ace"]
+				},
 			},
 			"config":{
 				"moment":{

+ 3 - 0
services/web/app/views/project/editor/editor.pug

@@ -58,6 +58,7 @@ div.full-size(
 			read-only="!permissions.write",
 			file-name="editor.open_doc_name",
 			on-ctrl-enter="recompileViaKey",
+			on-save="recompileViaKey",
 			on-ctrl-j="toggleReviewPanel",
 			on-ctrl-shift-c="addNewCommentFromKbdShortcut",
 			on-ctrl-shift-a="toggleTrackChangesFromKbdShortcut",
@@ -68,6 +69,8 @@ div.full-size(
 			track-changes= "editor.trackChanges",
 			doc-id="editor.open_doc_id"
 			renderer-data="reviewPanel.rendererData"
+			font-family="settings.fontFamily || ui.defaultFontFamily"
+			line-height="settings.lineHeight || ui.defaultLineHeight"
 		)
 
 		!= moduleIncludes('editor:body', locals)

+ 20 - 0
services/web/app/views/project/editor/left-menu.pug

@@ -150,6 +150,26 @@ aside#left-menu.full-size(
 					each size in ['10','11','12','13','14','16','20','24']
 						option(value=size) #{size}px
 
+			.form-controls
+				label(for="fontFamily") #{translate("font_family")}
+				select(
+					name="fontFamily"
+					ng-model="settings.fontFamily"
+				)
+					option(value="", disabled) #{translate("default")}
+					each fontFamily in ['monaco', 'lucida']
+						option(value=fontFamily) #{capitalize(fontFamily)}
+
+			.form-controls
+				label(for="lineHeight") #{translate("line_height")}
+				select(
+					name="lineHeight"
+					ng-model="settings.lineHeight"
+				)
+					option(value="", disabled) #{translate("default")}
+					each lineHeight in ['compact', 'normal', 'wide']
+						option(value=lineHeight) #{translate(lineHeight)}
+
 			.form-controls
 				label(for="pdfViewer") #{translate("pdf_viewer")}
 				select(

+ 4 - 2
services/web/app/views/project/list/project-list.pug

@@ -32,14 +32,16 @@
 						ng-click="downloadSelectedProjects()"
 					)
 						i.fa.fa-cloud-download
+					- var archiveButtonString = settings.overleaf ? translate("archive") : translate("delete")
+					- var archiveButtonIcon = settings.overleaf ? "fa-inbox" : "fa-trash-o"
 					a.btn.btn-default(
 						href,
-						tooltip=translate('delete'),
+						tooltip=`{{ isArchiveableProjectSelected ? '${archiveButtonString}' : '${translate("leave")}' }}`,
 						tooltip-placement="bottom",
 						tooltip-append-to-body="true",
 						ng-click="openArchiveProjectsModal()"
 					)
-						i.fa.fa-trash-o
+						i.fa(ng-class=`isArchiveableProjectSelected ? '${archiveButtonIcon}' : 'fa-sign-out'`)
 
 				.btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown)
 					a.btn.btn-default.dropdown-toggle(

+ 1 - 1
services/web/app/views/project/list/side-bar.pug

@@ -41,7 +41,7 @@
 		li(ng-class="{active: (filter == 'shared')}", ng-click="filterProjects('shared')")
 			a(href) #{translate("shared_with_you")}
 		li(ng-class="{active: (filter == 'archived')}", ng-click="filterProjects('archived')")
-			a(href) #{translate("deleted_projects")}
+			a(href) #{settings.overleaf ? translate("archived_projects") : translate("deleted_projects")}
 		if isShowingV1Projects
 			li(ng-class="{active: (filter == 'v1')}", ng-click="filterProjects('v1')")
 				a(href) #{translate("v1_projects")}

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

@@ -80,6 +80,8 @@ define [
 			miniReviewPanelVisible: false
 			chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen
 			chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed
+			defaultFontFamily: window.uiConfig.defaultFontFamily
+			defaultLineHeight: window.uiConfig.defaultLineHeight
 		}
 		$scope.user = window.user
 

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

@@ -3,6 +3,7 @@ define [
 	"ace/ace"
 	"ace/ext-searchbox"
 	"ace/ext-modelist"
+	"ace/keybinding-vim"
 	"ide/editor/directives/aceEditor/undo/UndoManager"
 	"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
 	"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
@@ -14,9 +15,10 @@ define [
 	"ide/graphics/services/graphics"
 	"ide/preamble/services/preamble"
     "ide/files/services/files"
-], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
+], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) ->
 	EditSession = ace.require('ace/edit_session').EditSession
 	ModeList = ace.require('ace/ext/modelist')
+	Vim = ace.require('ace/keyboard/vim').Vim
 
 	# set the path for ace workers if using a CDN (from editor.pug)
 	if window.aceWorkerPath != ""
@@ -60,6 +62,7 @@ define [
 				onCtrlJ: "="       # Toggle the review panel
 				onCtrlShiftC: "="  # Add a new comment
 				onCtrlShiftA: "="  # Toggle track-changes on/off
+				onSave: "="        # Cmd/Ctrl-S or :w in Vim
 				syntaxValidation: "="
 				reviewPanel: "="
 				eventsBridge: "="
@@ -67,6 +70,8 @@ define [
 				trackChangesEnabled: "="
 				docId: "="
 				rendererData: "="
+				lineHeight: "="
+				fontFamily: "="
 			}
 			link: (scope, element, attrs) ->
 				# Don't freak out if we're already in an apply callback
@@ -106,16 +111,26 @@ define [
 				metadataManager = new MetadataManager(scope, editor, element, metadata)
 				autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
 
-				# Prevert Ctrl|Cmd-S from triggering save dialog
-				editor.commands.addCommand
-					name: "save",
-					bindKey: win: "Ctrl-S", mac: "Command-S"
-					exec: () ->
-					readOnly: true
+				scope.$watch "onSave", (callback) ->
+					if callback?
+						Vim.defineEx 'write', 'w', callback
+						editor.commands.addCommand
+							name: "save",
+							bindKey: win: "Ctrl-S", mac: "Command-S"
+							exec: callback
+							readOnly: true
+						# Not technically 'save', but Ctrl-. recompiles in OL v1
+						# so maintain compatibility
+						editor.commands.addCommand
+							name: "recompile_v1",
+							bindKey: win: "Ctrl-.", mac: "Ctrl-."
+							exec: callback
+							readOnly: true
 				editor.commands.removeCommand "transposeletters"
 				editor.commands.removeCommand "showSettingsMenu"
 				editor.commands.removeCommand "foldall"
 
+
 				# For European keyboards, the / is above 7 so needs Shift pressing.
 				# This comes through as Command-Shift-/ on OS X, which is mapped to
 				# toggleBlockComment.
@@ -266,6 +281,29 @@ define [
 						"font-size": value + "px"
 					})
 
+				scope.$watch "fontFamily", (value) ->
+					if value?
+						switch value
+							when 'monaco'
+								editor.setOption('fontFamily', '"Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro", monospace')
+							when 'lucida'
+								editor.setOption('fontFamily', '"Lucida Console", monospace')
+							else
+								editor.setOption('fontFamily', null)
+
+				scope.$watch "lineHeight", (value) ->
+					if value?
+						switch value
+							when 'compact'
+								editor.container.style.lineHeight = 1.33
+							when 'normal'
+								editor.container.style.lineHeight = 1.6
+							when 'wide'
+								editor.container.style.lineHeight = 2
+							else
+								editor.container.style.lineHeight = 1.6
+						editor.renderer.updateFontSize()
+
 				scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
 					if old_sharejs_doc?
 						detachFromAce(old_sharejs_doc)

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

@@ -8,6 +8,12 @@ define [
 		if $scope.settings.pdfViewer not in ["pdfjs", "native"]
 			$scope.settings.pdfViewer = "pdfjs"
 
+		if $scope.settings.fontFamily? and $scope.settings.fontFamily not in ["monaco", "lucida"]
+			delete $scope.settings.fontFamily
+
+		if $scope.settings.lineHeight? and $scope.settings.lineHeight not in ["compact", "normal", "wide"]
+			delete $scope.settings.lineHeight
+
 		$scope.fontSizeAsStr = (newVal) ->
 			if newVal?
 				$scope.settings.fontSize = newVal
@@ -41,6 +47,14 @@ define [
 			if syntaxValidation != oldSyntaxValidation
 				settings.saveSettings({syntaxValidation: syntaxValidation})
 
+		$scope.$watch "settings.fontFamily", (fontFamily, oldFontFamily) =>
+			if fontFamily != oldFontFamily
+				settings.saveSettings({fontFamily: fontFamily})
+
+		$scope.$watch "settings.lineHeight", (lineHeight, oldLineHeight) =>
+			if lineHeight != oldLineHeight
+				settings.saveSettings({lineHeight: lineHeight})
+
 		$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
 			return if @ignoreUpdates
 			if oldLanguage? and language != oldLanguage

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

@@ -8,6 +8,7 @@ define [
 		$scope.notifications = window.data.notifications
 		$scope.allSelected = false
 		$scope.selectedProjects = []
+		$scope.isArchiveableProjectSelected = false
 		$scope.filter = "all"
 		$scope.predicate = "lastUpdated"
 		$scope.nUntagged = 0
@@ -85,6 +86,8 @@ define [
 
 		$scope.updateSelectedProjects = () ->
 			$scope.selectedProjects = $scope.projects.filter (project) -> project.selected
+			$scope.isArchiveableProjectSelected = $scope.selectedProjects.some (project) ->
+				window.user_id == project.owner._id
 
 		$scope.getSelectedProjects = () ->
 			$scope.selectedProjects

+ 71 - 0
services/web/public/js/ace-1.2.5/theme-overleaf.js

@@ -0,0 +1,71 @@
+ace.define("ace/theme/overleaf",["require","exports","module","ace/lib/dom"], function(require, exports, module) {
+"use strict";
+
+exports.isDark = false;
+exports.cssClass = "ace-overleaf";
+exports.cssText = ".ace-overleaf .ace_gutter {\
+background: #f0f0f0;\
+color: #333;\
+}\
+.ace-overleaf .ace_print-margin {\
+width: 1px;\
+background: #e8e8e8;\
+}\
+.ace-overleaf {\
+background-color: #FFFFFF;\
+color: black;\
+}\
+.ace-overleaf .ace_cursor {\
+color: black;\
+}\
+.ace-overleaf .ace_marker-layer .ace_selection {\
+background: rgb(181, 213, 255);\
+}\
+.ace-overleaf.ace_multiselect .ace_selection.ace_start {\
+box-shadow: 0 0 3px 0px white;\
+}\
+.ace-overleaf .ace_marker-layer .ace_step {\
+background: rgb(252, 255, 0);\
+}\
+.ace-overleaf .ace_marker-layer .ace_bracket {\
+border: 1px solid #5A5CAD;\
+}\
+.ace-overleaf .ace_marker-layer .ace_active-line {\
+background: rgba(0, 0, 0, 0.07);\
+}\
+.ace-overleaf .ace_gutter-active-line {\
+background-color: #dcdcdc;\
+}\
+.ace-overleaf .ace_marker-layer .ace_selected-word {\
+background: rgb(250, 250, 255);\
+border: 1px solid rgb(200, 200, 250);\
+}\
+.ace-overleaf .ace_fold {\
+background-color: #6B72E6;\
+}\
+.ace-overleaf .ace_comment {\
+color: #0080FF;\
+font-style: italic;\
+}\
+.ace-overleaf .ace_storage,\
+.ace-overleaf .ace_keyword {\
+color: #3F7F7F;\
+}\
+.ace-overleaf .ace_variable,\
+.ace-overleaf .ace_string {\
+color: #5A5CAD;\
+}\
+";
+exports.$id = "ace/theme/overleaf";
+
+var dom = require("../lib/dom");
+dom.importCssString(exports.cssText, exports.cssClass);
+});
+                (function() {
+                    ace.require(["ace/theme/overleaf"], function(m) {
+                        if (typeof module == "object" && typeof exports == "object" && module) {
+                            module.exports = m;
+                        }
+                    });
+                })();
+            

+ 5 - 2
services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee

@@ -22,6 +22,7 @@ describe "ProjectStructureMongoLock", ->
 		before (done) ->
 			# We want to instantly fail if the lock is taken
 			LockManager.MAX_LOCK_WAIT_TIME = 1
+			@lockValue = "lock-value"
 			userDetails =
 				holdingAccount:false,
 				email: 'test@example.com'
@@ -33,11 +34,13 @@ describe "ProjectStructureMongoLock", ->
 					@locked_project = project
 					namespace = ProjectEntityMongoUpdateHandler.LOCK_NAMESPACE
 					@lock_key = "lock:web:#{namespace}:#{project._id}"
-					LockManager._getLock @lock_key, namespace, done
+					LockManager._getLock @lock_key, namespace, (err, lockValue) =>
+						@lockValue = lockValue
+						done()
 			return
 
 		after (done) ->
-			LockManager._releaseLock @lock_key, done
+			LockManager._releaseLock @lock_key, @lockValue, done
 
 		describe 'interacting with the locked project', ->
 			LOCKING_UPDATE_METHODS = ['addDoc', 'addFile', 'mkdirp', 'moveEntity', 'renameEntity', 'addFolder']

+ 5 - 1
services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee

@@ -98,7 +98,11 @@ describe 'ProjectCreationHandler', ->
 
 			it "should set the overleaf id if overleaf id provided", (done)->
 				overleaf_id = 2345
-				@handler.createBlankProject ownerId, projectName, overleaf_id, (err, project)->
+				attributes =
+					overleaf:
+						history:
+							id: overleaf_id
+				@handler.createBlankProject ownerId, projectName, attributes, (err, project)->
 					project.overleaf.history.id.should.equal overleaf_id
 					done()
 

+ 13 - 6
services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee

@@ -872,6 +872,12 @@ describe 'ProjectEntityUpdateHandler', ->
 					.calledWith(@project, @entity, @path, userId)
 					.should.equal true
 
+			it "should should send the update to the doc updater", ->
+				oldDocs = [ doc: @entity, path: @path ]
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, {oldDocs})
+					.should.equal true
+
 		describe "a folder", ->
 			beforeEach (done) ->
 				@folder =
@@ -905,6 +911,13 @@ describe 'ProjectEntityUpdateHandler', ->
 					.calledWith(@project, @doc2, "/folder/doc-name-2", userId)
 					.should.equal true
 
+			it "should should send one update to the doc updater for all docs and files", ->
+				oldFiles = [ {file: @file2, path: "/folder/file-name-2"}, {file: @file1, path: "/folder/subfolder/file-name-1"} ]
+				oldDocs = [ {doc: @doc2, path: "/folder/doc-name-2"}, { doc: @doc1, path: "/folder/subfolder/doc-name-1"} ]
+				@DocumentUpdaterHandler.updateProjectStructure
+					.calledWith(project_id, projectHistoryId, userId, {oldFiles, oldDocs})
+					.should.equal true
+
 	describe "_cleanUpDoc", ->
 		beforeEach ->
 			@doc =
@@ -941,12 +954,6 @@ describe 'ProjectEntityUpdateHandler', ->
 					.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, projectHistoryId, userId, {oldDocs})
-					.should.equal true
-
 			it "should call the callback", ->
 				@callback.called.should.equal true
 

+ 8 - 6
services/web/test/unit/coffee/infrastructure/LockManager/ReleasingTheLock.coffee

@@ -3,23 +3,25 @@ assert = require('assert')
 path = require('path')
 modulePath = path.join __dirname, '../../../../../app/js/infrastructure/LockManager.js'
 lockKey = "lock:web:{#{5678}}"
+lockValue = "123456"
 SandboxedModule = require('sandboxed-module')
 
 describe 'LockManager - releasing the lock', ()->
 
-	deleteStub = sinon.stub().callsArgWith(1)
+	deleteStub = sinon.stub().callsArgWith(4)
 	mocks =
 		"logger-sharelatex": log:->
 
 		"./RedisWrapper":
 			client: ()->
 				auth:->
-				del:deleteStub
-	
-	LockManager = SandboxedModule.require(modulePath, requires: mocks)
+				eval:deleteStub
 
+	LockManager = SandboxedModule.require(modulePath, requires: mocks)
+	LockManager.unlockScript = "this is the unlock script"
+	
 	it 'should put a all data into memory', (done)->
-		LockManager._releaseLock lockKey, ->
-			deleteStub.calledWith(lockKey).should.equal true
+		LockManager._releaseLock lockKey, lockValue, ->
+			deleteStub.calledWith(LockManager.unlockScript, 1, lockKey, lockValue).should.equal true
 			done()
 

+ 2 - 1
services/web/test/unit/coffee/infrastructure/LockManager/tryLockTests.coffee

@@ -22,10 +22,11 @@ describe 'LockManager - trying the lock', ->
 	describe "when the lock is not set", ->
 		beforeEach ->
 			@set.callsArgWith(5, null, "OK")
+			@LockManager.randomLock = sinon.stub().returns("random-lock-value")
 			@LockManager._tryLock @key, @namespace, @callback
 
 		it "should set the lock key with an expiry if it is not set", ->
-			@set.calledWith(@key, "locked", "EX", 30, "NX")
+			@set.calledWith(@key, "random-lock-value", "EX", 30, "NX")
 				.should.equal true
 
 		it "should return the callback with true", ->