ソースを参照

Merge branch 'master' into i18n

Henry Oswald 12 年 前
親
コミット
aa4f748608
28 ファイル変更、406 行追加、104 行削除
  1. 7 5
      services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee
  2. 15 0
      services/web/app/coffee/Features/Dropbox/DropboxRouter.coffee
  3. 19 0
      services/web/app/coffee/Features/Dropbox/DropboxWebhookController.coffee
  4. 49 0
      services/web/app/coffee/Features/Dropbox/DropboxWebhookHandler.coffee
  5. 4 3
      services/web/app/coffee/Features/ServerAdmin/AdminController.coffee
  6. 11 1
      services/web/app/coffee/Features/Templates/TemplatesWebController.coffee
  7. 10 1
      services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee
  8. 4 7
      services/web/app/coffee/router.coffee
  9. 9 4
      services/web/app/views/admin.jade
  10. 0 4
      services/web/app/views/project/editor/dropbox.jade
  11. 21 18
      services/web/app/views/project/editor/file-tree.jade
  12. 11 0
      services/web/app/views/project/editor/header.jade
  13. 2 2
      services/web/app/views/project/editor/pdf.jade
  14. 3 3
      services/web/app/views/referal/bonus.jade
  15. 3 0
      services/web/app/views/templates/index.jade
  16. 3 3
      services/web/app/views/templates/template.jade
  17. 1 11
      services/web/public/coffee/ide/dropbox/controllers/DropboxController.coffee
  18. 1 0
      services/web/public/coffee/ide/pdf/PdfManager.coffee
  19. 17 0
      services/web/public/coffee/ide/pdf/controllers/PdfViewToggleController.coffee
  20. 10 2
      services/web/public/js/ace/ace.js
  21. 13 1
      services/web/public/stylesheets/app/editor/file-tree.less
  22. 0 14
      services/web/public/stylesheets/app/editor/pdf.less
  23. 14 2
      services/web/public/stylesheets/app/editor/toolbar.less
  24. 6 0
      services/web/public/stylesheets/app/templates.less
  25. 9 23
      services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee
  26. 48 0
      services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookControllerTests.coffee
  27. 99 0
      services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookHandlerTests.coffee
  28. 17 0
      services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee

+ 7 - 5
services/web/app/coffee/Features/DocumentUpdater/DocumentUpdaterHandler.coffee

@@ -18,12 +18,14 @@ module.exports = DocumentUpdaterHandler =
 	queueChange : (project_id, doc_id, change, sl_req_id, callback = ()->)->
 		{callback, sl_req_id} = slReqIdHelper.getCallbackAndReqId(callback, sl_req_id)
 		jsonChange = JSON.stringify change
-		rclient.rpush keys.pendingUpdates(doc_id:doc_id), jsonChange, (error)->
+		doc_key = keys.combineProjectIdAndDocId(project_id, doc_id)
+		multi = rclient.multi()
+		multi.rpush keys.pendingUpdates(doc_id:doc_id), jsonChange
+		multi.sadd  keys.docsWithPendingUpdates, doc_key
+		multi.rpush "pending-updates-list", doc_key
+		multi.exec (error) ->
 			return callback(error) if error?
-			doc_key = keys.combineProjectIdAndDocId(project_id, doc_id)
-			rclient.sadd keys.docsWithPendingUpdates, doc_key, (error) ->
-				return callback(error) if error?
-				rclient.publish "pending-updates", doc_key, callback
+			callback()
 
 	flushProjectToMongo: (project_id, sl_req_id, callback = (error) ->)->
 		{callback, sl_req_id} = slReqIdHelper.getCallbackAndReqId(callback, sl_req_id)

+ 15 - 0
services/web/app/coffee/Features/Dropbox/DropboxRouter.coffee

@@ -0,0 +1,15 @@
+DropboxUserController = require './DropboxUserController'
+DropboxWebhookController = require './DropboxWebhookController'
+
+module.exports =
+	apply: (app) ->
+		app.get  '/dropbox/beginAuth', DropboxUserController.redirectUserToDropboxAuth
+		app.get  '/dropbox/completeRegistration', DropboxUserController.completeDropboxRegistration
+		app.get  '/dropbox/unlink', DropboxUserController.unlinkDropbox
+		
+		app.get  '/dropbox/webhook', DropboxWebhookController.verify
+		app.post '/dropbox/webhook', DropboxWebhookController.webhook
+		app.ignoreCsrf('post', '/dropbox/webhook')
+
+
+

+ 19 - 0
services/web/app/coffee/Features/Dropbox/DropboxWebhookController.coffee

@@ -0,0 +1,19 @@
+logger = require("logger-sharelatex")
+DropboxWebhookHandler = require("./DropboxWebhookHandler")
+
+module.exports = DropboxWebhookController =
+	verify: (req, res, next = (error) ->) ->
+		res.send(req.query.challenge)
+		
+	webhook: (req, res, next = (error) ->) ->
+		dropbox_uids = req.body?.delta?.users
+		logger.log dropbox_uids: dropbox_uids, "received webhook request from Dropbox"
+		if !dropbox_uids?
+			return res.send(400) # Bad Request
+			
+		# Do this in the background so as not to keep Dropbox waiting
+		DropboxWebhookHandler.pollDropboxUids dropbox_uids, (error) ->
+			if error?
+				logger.error err: error, dropbox_uids: dropbox_uids, "error in webhook"
+		
+		res.send(200)

+ 49 - 0
services/web/app/coffee/Features/Dropbox/DropboxWebhookHandler.coffee

@@ -0,0 +1,49 @@
+logger = require("logger-sharelatex")
+settings = require("settings-sharelatex")
+async = require "async"
+User = require("../../models/User").User
+TpdsUpdateSender = require "../ThirdPartyDataStore/TpdsUpdateSender"
+
+redis = require('redis')
+rclient = redis.createClient(settings.redis.web.port, settings.redis.web.host)
+rclient.auth(settings.redis.web.password)
+
+module.exports = DropboxWebhookHandler =
+	pollDropboxUids: (dropbox_uids, callback = (error) ->) ->
+		jobs = []
+		for uid in dropbox_uids
+			do (uid) ->
+				jobs.push (callback) ->
+					DropboxWebhookHandler.pollDropboxUid uid, callback
+		async.series jobs, callback
+		
+	pollDropboxUid: (dropbox_uid, callback = (error) ->) ->
+		DropboxWebhookHandler._delayAndBatchPoll dropbox_uid, (error, shouldPoll) ->
+			return callback(error) if error?
+			return callback() if !shouldPoll
+			User.find {
+				"dropbox.access_token.uid": dropbox_uid.toString()
+				"features.dropbox": true
+			}, (error, users = []) ->
+				return callback(error) if error?
+				user = users[0]
+				if !user?
+					logger.log dropbox_uid: dropbox_uid, "no sharelatex user found"
+					return callback()
+				TpdsUpdateSender.pollDropboxForUser user._id, callback
+		
+	POLL_DELAY_IN_MS: 5000 # 5 seconds
+	_delayAndBatchPoll: (dropbox_uid, callback = (error, shouldPoll) ->) ->
+		rclient.set(
+			"dropbox-poll-lock:#{dropbox_uid}", "LOCK",
+			"PX", DropboxWebhookHandler.POLL_DELAY_IN_MS,
+			"NX",
+		(error, gotLock) ->
+			return callback(error) if error?
+			if gotLock
+				setTimeout () ->
+					callback(null, true)
+				, DropboxWebhookHandler.POLL_DELAY_IN_MS
+			else
+				callback(null, false)
+		)

+ 4 - 3
services/web/app/coffee/Features/ServerAdmin/AdminController.coffee

@@ -12,7 +12,7 @@ rclient.auth(Settings.redis.web.password)
 RecurlyWrapper = require('../Subscription/RecurlyWrapper')
 SubscriptionHandler = require('../Subscription/SubscriptionHandler')
 projectEntityHandler = require('../Project/ProjectEntityHandler')
-TpdsPollingBackgroundTasks = require("../ThirdPartyDataStore/TpdsPollingBackgroundTasks")
+TpdsUpdateSender = require("../ThirdPartyDataStore/TpdsUpdateSender")
 EditorRealTimeController = require("../Editor/EditorRealTimeController")
 SystemMessageManager = require("../SystemMessages/SystemMessageManager")
 
@@ -74,8 +74,9 @@ module.exports = AdminController =
 		projectEntityHandler.flushProjectToThirdPartyDataStore req.body.project_id, (err)->
 			res.send 200
 
-	pollUsersWithDropbox: (req, res)->
-		TpdsPollingBackgroundTasks.pollUsersWithDropbox ->
+	pollDropboxForUser: (req, res)->
+		user_id = req.body.user_id
+		TpdsUpdateSender.pollDropboxForUser user_id, () ->
 			res.send 200
 			
 	createMessage: (req, res, next) ->

+ 11 - 1
services/web/app/coffee/Features/Templates/TemplatesWebController.coffee

@@ -36,7 +36,17 @@ module.exports = TemplatesWebController =
 
 	proxyToTemplatesApi: (req, res)->
 		url = req.url
-		logger.log url:url, "proxying request to templates api"
+		
+		name = req.query.name or "Template"
+		if req.query.inline?
+			disposition = "inline"
+		else
+			disposition = "attachment"
+		console.log "HEADER", "#{disposition}; filename=#{name};"
+		res.header({"content-disposition": "#{disposition}; filename=#{name}.#{req.params.file_type};"})
+		
+		logger.log url:url, template_name: name, disposition: disposition, "proxying request to templates api"
+		
 		getReq = request.get("#{settings.apis.templates_api.url}#{url}")
 		getReq.pipe(res)
 		getReq.on "error", (error) ->

+ 10 - 1
services/web/app/coffee/Features/ThirdPartyDataStore/TpdsUpdateSender.coffee

@@ -87,7 +87,16 @@ module.exports =
 				title:"deleteEntity"
 				sl_all_user_ids:JSON.stringify(allUserIds)
 			queue.enqueue options.project_id, "standardHttpRequest", deleteOptions, callback
-
+			
+	pollDropboxForUser: (user_id, callback = (err) ->) ->
+		metrics.inc("tpds.poll-dropbox")
+		logger.log user_id: user_id, "polling dropbox for user"
+		options =
+			method: "POST"
+			uri:"#{settings.apis.thirdPartyDataStore.url}/user/poll"
+			json:
+				user_ids: [user_id]
+		queue.enqueue "poll-dropbox:#{user_id}", "standardHttpRequest", options, callback
 
 getProjectsUsersIds = (project_id, callback = (err, owner_id, allUserIds)->)->
 	Project.findById project_id, "_id owner_ref readOnly_refs collaberator_refs", (err, project)->

+ 4 - 7
services/web/app/coffee/router.coffee

@@ -10,7 +10,6 @@ EditorHttpController = require("./Features/Editor/EditorHttpController")
 EditorUpdatesController = require("./Features/Editor/EditorUpdatesController")
 Settings = require('settings-sharelatex')
 TpdsController = require('./Features/ThirdPartyDataStore/TpdsController')
-dropboxHandler = require('./Features/Dropbox/DropboxHandler')
 SubscriptionRouter = require './Features/Subscription/SubscriptionRouter'
 UploadsRouter = require './Features/Uploads/UploadsRouter'
 metrics = require('./infrastructure/Metrics')
@@ -32,13 +31,14 @@ HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
 TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
-DropboxUserController = require("./Features/Dropbox/DropboxUserController")
 PasswordResetRouter = require("./Features/PasswordReset/PasswordResetRouter")
 StaticPagesRouter = require("./Features/StaticPages/StaticPagesRouter")
 ChatController = require("./Features/Chat/ChatController")
 BlogController = require("./Features/Blog/BlogController")
 WikiController = require("./Features/Wiki/WikiController")
 ConnectedUsersController = require("./Features/ConnectedUsers/ConnectedUsersController")
+DropboxRouter = require "./Features/Dropbox/DropboxRouter"
+dropboxHandler = require "./Features/Dropbox/DropboxHandler"
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -66,6 +66,7 @@ module.exports = class Router
 		PasswordResetRouter.apply(app)
 		StaticPagesRouter.apply(app)
 		TemplatesRouter.apply(app)
+		DropboxRouter.apply(app)
 
 		app.get '/blog', BlogController.getIndexPage
 		app.get '/blog/*', BlogController.getPage
@@ -80,10 +81,6 @@ module.exports = class Router
 		app.del  '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
 		app.del  '/user', AuthenticationController.requireLogin(), UserController.deleteUser
 
-		app.get  '/dropbox/beginAuth', DropboxUserController.redirectUserToDropboxAuth
-		app.get  '/dropbox/completeRegistration', DropboxUserController.completeDropboxRegistration
-		app.get  '/dropbox/unlink', DropboxUserController.unlinkDropbox
-
 		app.get  '/user/auth_token', AuthenticationController.requireLogin(), AuthenticationController.getAuthToken
 		app.get  '/user/personal_info', AuthenticationController.requireLogin(allow_auth_token: true), UserInfoController.getLoggedInUsersPersonalInfo
 		app.get  '/user/:user_id/personal_info', httpAuth, UserInfoController.getPersonalInfo
@@ -172,7 +169,7 @@ module.exports = class Router
 		app.post '/admin/dissconectAllUsers', SecurityManager.requestIsAdmin, AdminController.dissconectAllUsers
 		app.post '/admin/syncUserToSubscription', SecurityManager.requestIsAdmin, AdminController.syncUserToSubscription
 		app.post '/admin/flushProjectToTpds', SecurityManager.requestIsAdmin, AdminController.flushProjectToTpds
-		app.post '/admin/pollUsersWithDropbox', SecurityManager.requestIsAdmin, AdminController.pollUsersWithDropbox
+		app.post '/admin/pollDropboxForUser', SecurityManager.requestIsAdmin, AdminController.pollDropboxForUser
 		app.post '/admin/messages', SecurityManager.requestIsAdmin, AdminController.createMessage
 		app.post '/admin/messages/clear', SecurityManager.requestIsAdmin, AdminController.clearMessages
 

+ 9 - 4
services/web/app/views/admin.jade

@@ -45,7 +45,7 @@ block content
 										.form-group
 											button.btn-primary.btn(type='submit') Link
 											
-							tab(heading="TPDS Management")
+							tab(heading="TPDS/Dropbox Management")
 								h3 Flush project to TPDS
 								.row
 									form.col-xs-6(enctype='multipart/form-data', method='post',action='/admin/flushProjectToTpds')
@@ -56,10 +56,15 @@ block content
 										.form-group
 											button.btn-primary.btn(type='submit') Flush
 								hr
-								.row-spaced
-									form(enctype='multipart/form-data', method='post',action='/admin/pollUsersWithDropbox')
+								h3 Poll Dropbox for user
+								.row
+									form.col-xs-6(enctype='multipart/form-data', method='post',action='/admin/pollDropboxForUser')
 										input(name="_csrf", type="hidden", value=csrfToken)
-										button.btn.btn-primary(type="submit") Poll users with dropbox
+										.form-group
+											label(for='user_id') user_id 
+											input.form-control(type='text',  name='user_id', placeholder='user_id', required)
+										.form-group
+											button.btn-primary.btn(type='submit') Poll
 										
 							tab(heading="System Messages")
 								each message in systemMessages 

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

@@ -20,10 +20,6 @@ script(type="text/ng-template", id="dropboxModalTemplate")
 
 
 			div(ng-show="dbState.hasDropboxFeature && dbState.userIsLinkedToDropbox")
-				progressbar.progress-striped.active(value='dbState.percentageLeftTillNextPoll', type="info")
-				p
-					strong {{dbState.minsTillNextPoll}} #{translate("minutes")} 
-					span #{translate("until_db_checked_for_changes")}
 				p.small
 					| #{translate("this_project_will_appear_in_your_dropbox_folder_at")}
 					strong Dropbox/sharelatex/{{ project.name }}

+ 21 - 18
services/web/app/views/project/editor/file-tree.jade

@@ -46,14 +46,15 @@ aside#file-tree(ng-controller="FileTreeController").full-size
 		ng-class="{ 'no-toolbar': !permissions.write }"
 	)
 
-		div(ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf')")
+		div(ng-show="ui.pdfLayout == 'flat' && (ui.view == 'editor' || ui.view == 'pdf' || ui.view == 'file')")
 			ul.list-unstyled.file-tree-list
 				li(
 					ng-class="{ 'selected': ui.view == 'pdf' }"
+					ng-controller="PdfViewToggleController"
 				)
 					.entity
 						.entity-name(
-							ng-click="ui.view = 'pdf'"
+							ng-click="togglePdfView()"
 						)
 							i.fa.fa-fw.toggle
 							i.fa.fa-fw.fa-file-pdf-o
@@ -115,14 +116,15 @@ script(type='text/ng-template', id='entityListItemTemplate')
 				span(
 					ng-hide="entity.renaming"
 				)  {{ entity.name }}
-				input(
-					ng-if="permissions.write",
-					ng-show="entity.renaming",
-					ng-model="inputs.name",
-					ng-blur="finishRenaming()",
-					select-name-when="entity.renaming",
-					on-enter="finishRenaming()"
-				)
+				span.rename-input
+					input(
+						ng-if="permissions.write",
+						ng-show="entity.renaming",
+						ng-model="inputs.name",
+						ng-blur="finishRenaming()",
+						select-name-when="entity.renaming",
+						on-enter="finishRenaming()"
+					)
 
 				span.dropdown(
 					ng-show="entity.selected",
@@ -197,14 +199,15 @@ script(type='text/ng-template', id='entityListItemTemplate')
 					span(
 						ng-hide="entity.renaming"
 					)  {{ entity.name }}
-					input(
-						ng-if="permissions.write",
-						ng-show="entity.renaming",
-						ng-model="inputs.name",
-						ng-blur="finishRenaming()",
-						select-name-when="entity.renaming",
-						on-enter="finishRenaming()"
-					)
+					span.rename-input
+						input(
+							ng-if="permissions.write",
+							ng-show="entity.renaming",
+							ng-model="inputs.name",
+							ng-blur="finishRenaming()",
+							select-name-when="entity.renaming",
+							on-enter="finishRenaming()"
+						)
 
 					span.dropdown(
 						ng-if="permissions.write"

+ 11 - 0
services/web/app/views/project/editor/header.jade

@@ -14,6 +14,17 @@ header.toolbar.toolbar-header(ng-cloak, ng-hide="state.loading")
 		tooltip-append-to-body="true"
 	)
 		i.fa.fa-fw.fa-level-up
+	span(ng-controller="PdfViewToggleController")
+		a(
+			href,
+			ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
+			tooltip="PDF",
+			tooltip-placement="bottom",
+			tooltip-append-to-body="true",
+			ng-click="togglePdfView()",
+			ng-class="{ 'active': ui.view == 'pdf' }"
+		)
+			i.fa.fa-file-pdf-o
 
 	.toolbar-center.project-name(ng-controller="ProjectNameController")
 		span.name(

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

@@ -80,12 +80,12 @@ div.full-size.pdf(ng-controller="PdfController")
 
 	.pdf-errors(ng-show="pdf.timedout || pdf.error")
 		.alert.alert-danger(ng-show="pdf.error")
-			strong #{translate("server_error")}
+			strong #{translate("server_error")} 
 			span #{translate("somthing_went_wrong_compiling")}
 
 		.alert.alert-danger(ng-show="pdf.timedout")
 			p
-				strong #{translate("timedout")}.
+				strong #{translate("timedout")}. 
 				span #{translate("proj_timed_out_reason")}
 			p
 				a.text-info(href="https://www.sharelatex.com/learn/Debugging_Compilation_timeout_errors", target="_blank")

+ 3 - 3
services/web/app/views/referal/bonus.jade

@@ -80,11 +80,11 @@ block content
 							.row.ab-bonus
 								.col-md-10.col-md-offset-1.bonus-banner
 									- if (refered_user_count == 0)
-										p.thanks #{translate("you_not_introed_anyone_to_sl")}
+										p.thanks !{translate("you_not_introed_anyone_to_sl")}
 									- else if (refered_user_count == 1)
-										p.thanks #{translate("you_introed_small_number", {numberOfPeople:"<strong>#{refered_user_count}</strong>"})}
+										p.thanks !{translate("you_introed_small_number", {numberOfPeople:"<strong>" + refered_user_count + "</strong>"})}
 									- else
-										p.thanks #{translate("you_introed_high_number", {numberOfPeople:"<strong>#{refered_user_count}</strong>"})}
+										p.thanks !{translate("you_introed_high_number", {numberOfPeople:"<strong>" + refered_user_count + "</strong>"})}
 						
 						script(type="text/ng-template", id="BonusLinkToUsModal")
 							.modal-header

+ 3 - 0
services/web/app/views/templates/index.jade

@@ -1,5 +1,8 @@
 extends ../layout
 
+block vars
+	- var meta = "Over 400 LaTeX templates for journal articles, theses, CV and resumes, posters, presentations, and much more"
+
 block content
 	.content.content-alt
 		.container

+ 3 - 3
services/web/app/views/templates/template.jade

@@ -20,8 +20,8 @@ block content
 					.col-md-6
 						.entry
 							.row
-								.col-md-12
-									a(href=template.pdfUrl)
+								.col-md-12.template-large-pdf-preview
+									a(href="#{template.pdfUrl}?inline=true&name=#{template.name}")
 										img(src="#{template.previewUrl}")
 
 					.col-md-6
@@ -33,7 +33,7 @@ block content
 								| &nbsp;
 
 								a.btn.btn-default(
-									href=template.zipUrl,
+									href="#{template.zipUrl}?name=#{template.name}",
 									rel='nofollow', 
 									ng-click='downloadZip()',
 									tooltip-placement="bottom",

+ 1 - 11
services/web/public/coffee/ide/dropbox/controllers/DropboxController.coffee

@@ -27,20 +27,10 @@ define [
 
 		$scope.dbState = cachedState
 		$scope.dbState.hasDropboxFeature = $scope.project.features.dropbox
-		
-		calculatePollTime = ->
-			ide.socket.emit "getLastTimePollHappned", (err, lastTimePollHappened)=>
-				milisecondsSinceLastPoll = new Date().getTime() - lastTimePollHappened
-				roundedMinsSinceLastPoll = Math.round(milisecondsSinceLastPoll / ONE_MIN_MILI)
-
-				$scope.dbState.minsTillNextPoll = POLLING_INTERVAL - roundedMinsSinceLastPoll
-				$scope.dbState.percentageLeftTillNextPoll = ((roundedMinsSinceLastPoll / POLLING_INTERVAL) * 100)
-				$timeout calculatePollTime, 60 * 1000
 
 		ide.socket.emit "getUserDropboxLinkStatus", user_id, (err, status)=>
 			$scope.dbState.gotLinkStatus = true
-			if status.registered 
-				calculatePollTime()
+			if status.registered
 				$scope.dbState.userIsLinkedToDropbox = true
 				cachedState = $scope.dbState
 		

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

@@ -1,5 +1,6 @@
 define [
 	"ide/pdf/controllers/PdfController"
+	"ide/pdf/controllers/PdfViewToggleController"
 	"ide/pdf/directives/pdfJs"
 ], () ->
 	class PdfManager

+ 17 - 0
services/web/public/coffee/ide/pdf/controllers/PdfViewToggleController.coffee

@@ -0,0 +1,17 @@
+define [
+	"base"
+], (App) ->
+	App.controller "PdfViewToggleController", ($scope) ->
+		$scope.togglePdfView = () ->
+			if $scope.ui.view == "pdf"
+				$scope.ui.view = "editor"
+			else
+				$scope.ui.view = "pdf"
+		
+		$scope.fileTreeClosed = false		
+		$scope.$on "layout:main:resize", (e, state) ->
+			if state.west.initClosed
+				$scope.fileTreeClosed = true
+			else
+				$scope.fileTreeClosed = false
+			$scope.$apply()

+ 10 - 2
services/web/public/js/ace/ace.js

@@ -11428,6 +11428,7 @@ var Editor = function(renderer, session) {
 
         this._signal("change", e);
         this.$cursorChange();
+        this.$updateHighlightActiveLine();
     };
 
     this.onTokenizerUpdate = function(e) {
@@ -14853,8 +14854,10 @@ var VirtualRenderer = function(container, theme) {
                 this.$changedLines.lastRow = lastRow;
         }
 
-        if (this.$changedLines.firstRow > this.layerConfig.lastRow ||
-            this.$changedLines.lastRow < this.layerConfig.firstRow)
+        if (this.$changedLines.lastRow < this.layerConfig.firstRow)
+            this.$changedLines.lastRow = this.layerConfig.lastRow
+
+        if (this.$changedLines.firstRow > this.layerConfig.lastRow)
             return;
         this.$loop.schedule(this.CHANGE_LINES);
     };
@@ -15218,6 +15221,11 @@ var VirtualRenderer = function(container, theme) {
             changes & this.CHANGE_H_SCROLL
         ) {
             changes |= this.$computeLayerConfig();
+            if (config.firstRow != this.layerConfig.firstRow && config.firstRowScreen == this.layerConfig.firstRowScreen) {
+                this.scrollTop = this.scrollTop + (config.firstRow - this.layerConfig.firstRow) * this.lineHeight;
+                changes = changes | this.CHANGE_SCROLL;
+                changes |= this.$computeLayerConfig();
+            }
             config = this.layerConfig;
             this.$updateScrollBarV();
             if (changes & this.CHANGE_H_SCROLL)

+ 13 - 1
services/web/public/stylesheets/app/editor/file-tree.less

@@ -31,6 +31,7 @@ aside#file-tree {
 
 		li {
 			line-height: 2.6;
+			position: relative;
 
 			.entity-name {
 				color: @gray-darker;
@@ -71,7 +72,7 @@ aside#file-tree {
 					color: @link-color;
 					border-right: 4px solid @link-color;
 					font-weight: bold;
-					i.fa-folder-open, i.fa-folder, i.fa-file, i.fa-image {
+					i.fa-folder-open, i.fa-folder, i.fa-file, i.fa-image, i.fa-file-pdf-o {
 						color: @link-color;
 					}
 					padding-right: 32px;
@@ -85,6 +86,17 @@ aside#file-tree {
 					padding: 0 12px;
 				}
 			}
+			
+			.rename-input {
+				display: block;
+				position: absolute;
+				top: 1px;
+				left: 44px;
+				right: 32px;
+				input {
+					width: 100%;
+				}
+			}
 		}
 	}
 

+ 0 - 14
services/web/public/stylesheets/app/editor/pdf.less

@@ -74,20 +74,6 @@
 }
 
 .pdf .toolbar {
-	.log-btn {
-		&.active, &:active {
-			.label {
-				display: none;
-			}
-			color: white;
-			background-color: @link-color;
-			.box-shadow(inset 0 3px 5px rgba(0, 0, 0, 0.225));
-			&:hover {
-				color: white;
-			}
-		}
-	}
-	
 	.toolbar-right {
 		margin-right: @line-height-computed / 2;
 		a {

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

@@ -16,14 +16,25 @@
 	a:not(.btn) {
 		display: inline-block;
 		color: @gray-light;
-		padding: 5px 12px 6px;
-		margin: 0;
+		padding: 4px 10px 5px;
+		margin: 1px 2px;
 		border-radius: @border-radius-small;
 		&:hover {
 			text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
 			color: @gray-dark;
 			text-decoration: none;
 		}
+		&.active, &:active {
+			.label {
+				display: none;
+			}
+			color: white;
+			background-color: @link-color;
+			.box-shadow(inset 0 3px 5px rgba(0, 0, 0, 0.225));
+			&:hover {
+				color: white;
+			}
+		}
 	}
 
 	.btn-full-height {
@@ -81,6 +92,7 @@
 		height: 32px;
 		a {
 			padding: 4px 2px 2px;
+			margin: 0;
 			margin-left: 6px;
 		}
 		.toolbar-right {

+ 6 - 0
services/web/public/stylesheets/app/templates.less

@@ -56,4 +56,10 @@
 		max-width: 100%;    
 		height: auto;
 	}
+}
+
+.template-large-pdf-preview {
+	img {
+		max-width: 100%;
+	}
 }

+ 9 - 23
services/web/test/UnitTests/coffee/DocumentUpdater/DocumentUpdaterHandlerTests.coffee

@@ -40,9 +40,10 @@ describe 'Flushing documents :', ->
 				"range":{"start":{"row":2,"column":2},"end":{"row":2,"column":3}},
 				"text":"e"
 			}
-			@rclient.rpush = sinon.stub().callsArg(2)
-			@rclient.publish = sinon.stub().callsArg(2)
-			@rclient.sadd = sinon.stub().callsArg(2)
+			@rclient.multi = sinon.stub().returns @rclient
+			@rclient.exec = sinon.stub().callsArg(0)
+			@rclient.rpush = sinon.stub()
+			@rclient.sadd = sinon.stub()
 			@callback = sinon.stub()
 
 		describe "successfully", ->
@@ -54,9 +55,9 @@ describe 'Flushing documents :', ->
 					.calledWith("PendingUpdates:#{@doc_id}", JSON.stringify(@change))
 					.should.equal true
 
-			it "should notify the doc updater of the change via pub/sub", ->
-				@rclient.publish
-					.calledWith("pending-updates", "#{@project_id}:#{@doc_id}")
+			it "should notify the doc updater of the change via the pending-updates-list queue", ->
+				@rclient.rpush
+					.calledWith("pending-updates-list", "#{@project_id}:#{@doc_id}")
 					.should.equal true
 
 			it "should push the doc id into the pending updates set", ->
@@ -64,29 +65,14 @@ describe 'Flushing documents :', ->
 					.calledWith("DocsWithPendingUpdates", "#{@project_id}:#{@doc_id}")
 					.should.equal true
 
-		describe "with error connecting to redis during push", ->
-			beforeEach ->
-				@rclient.rpush = sinon.stub().callsArgWith(2, new Error("something went wrong"))
-				@handler.queueChange(@project_id, @doc_id, @change, @callback)
-
-			it "should return an error", ->
-				@callback.calledWithExactly(sinon.match(Error)).should.equal true
-
-		describe "with error connecting to redis during publish", ->
+		describe "with error connecting to redis during exec", ->
 			beforeEach ->
-				@rclient.publish = sinon.stub().callsArgWith(2, new Error("something went wrong"))
+				@rclient.exec = sinon.stub().callsArgWith(0, new Error("something went wrong"))
 				@handler.queueChange(@project_id, @doc_id, @change, @callback)
 
 			it "should return an error", ->
 				@callback.calledWithExactly(sinon.match(Error)).should.equal true
 
-		describe "with error connecting to redis during sadd", ->
-			beforeEach ->
-				@rclient.sadd = sinon.stub().callsArgWith(2, new Error("something went wrong"))
-				@handler.queueChange(@project_id, @doc_id, @change, @callback)
-
-			it "should return an error", ->
-				@callback.calledWithExactly(sinon.match(Error)).should.equal true
 
 	describe 'flushProjectToMongo', ->
 		beforeEach ->

+ 48 - 0
services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookControllerTests.coffee

@@ -0,0 +1,48 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Dropbox/DropboxWebhookController.js'
+
+describe 'DropboxWebhookController', ->
+	beforeEach ->
+
+		@DropboxWebhookController = SandboxedModule.require modulePath, requires:
+			"./DropboxWebhookHandler": @DropboxWebhookHandler = {}
+			'logger-sharelatex':
+				log:->
+				err:->
+
+	describe "verify", ->
+		beforeEach ->
+			@res =
+				send: sinon.stub()
+			@req.query =
+				challenge: @challenge = "foo"
+			@DropboxWebhookController.verify(@req, @res)
+			
+		it "should echo the challenge parameter back", ->
+			@res.send.calledWith(@challenge).should.equal true
+			
+	describe "webhook", ->
+		beforeEach ->
+			@req.body =
+				delta:
+					users: @dropbox_uids = [
+						"123456",
+						"789123"
+					]
+			@res.send = sinon.stub()
+			@DropboxWebhookHandler.pollDropboxUids = sinon.stub().callsArg(1)
+			@DropboxWebhookController.webhook(@req, @res)
+			
+		it "should poll the Dropbox uids", ->
+			@DropboxWebhookHandler.pollDropboxUids
+				.calledWith(@dropbox_uids)
+				.should.equal true
+				
+		it "should return success", ->
+			@res.send
+				.calledWith(200)
+				.should.equal true
+

+ 99 - 0
services/web/test/UnitTests/coffee/Dropbox/DropboxWebhookHandlerTests.coffee

@@ -0,0 +1,99 @@
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+require('chai').should()
+expect = require("chai").expect
+sinon = require('sinon')
+modulePath = require('path').join __dirname, '../../../../app/js/Features/Dropbox/DropboxWebhookHandler.js'
+
+describe 'DropboxWebhookHandler', ->
+	beforeEach ->
+		@DropboxWebhookHandler = SandboxedModule.require modulePath, requires:
+			"../../models/User": User: @User = {}
+			"../ThirdPartyDataStore/TpdsUpdateSender": @TpdsUpdateSender = {}
+			"redis":
+				createClient: () => @rclient =
+					auth: sinon.stub()
+			'settings-sharelatex': redis: web: {}
+			'logger-sharelatex':
+				log:->
+				err:->
+		@callback = sinon.stub()
+
+	describe "pollDropboxUids", ->
+		beforeEach (done) ->
+			@dropbox_uids = [
+				"123456",
+				"789123"
+			]
+			@DropboxWebhookHandler.pollDropboxUid = sinon.stub().callsArg(1)
+			@DropboxWebhookHandler.pollDropboxUids @dropbox_uids, done
+			
+		it "should call pollDropboxUid for each uid", ->
+			for uid in @dropbox_uids
+				@DropboxWebhookHandler.pollDropboxUid
+					.calledWith(uid)
+					.should.equal true
+					
+	describe "pollDropboxUid", ->
+		beforeEach ->
+			@dropbox_uid = "dropbox-123456"
+			@user_id = "sharelatex-user-id"
+			@User.find = sinon.stub().callsArgWith(1, null, [ _id: @user_id ])
+			@TpdsUpdateSender.pollDropboxForUser = sinon.stub().callsArg(1)
+			
+		describe "when there is already a poll in progress", () ->
+			beforeEach ->
+				@DropboxWebhookHandler._delayAndBatchPoll = sinon.stub().callsArgWith(1, null, false)
+				@DropboxWebhookHandler.pollDropboxUid @dropbox_uid, @callback
+				
+			it "should not go ahead with the poll", ->
+				@TpdsUpdateSender.pollDropboxForUser.called.should.equal false
+				
+		describe "when we are the one to do the delayed poll", () ->
+			beforeEach ->
+				@DropboxWebhookHandler._delayAndBatchPoll = sinon.stub().callsArgWith(1, null, true)
+				@DropboxWebhookHandler.pollDropboxUid @dropbox_uid, @callback
+				
+			it "should look up the user", ->
+				@User.find
+					.calledWith({ "dropbox.access_token.uid": @dropbox_uid, "features.dropbox": true })
+					.should.equal true
+					
+			it "should poll the user's Dropbox", ->
+				@TpdsUpdateSender.pollDropboxForUser
+					.calledWith(@user_id)
+					.should.equal true
+					
+			it "should call the callback", ->
+				@callback.called.should.equal true
+				
+	describe "_delayAndBatchPoll", () ->
+		beforeEach ->
+			@dropbox_uid = "dropbox-uid-123"
+			@DropboxWebhookHandler.POLL_DELAY_IN_MS = 100
+			
+		describe "when no one else is polling yet", ->
+			beforeEach (done) ->
+				@rclient.set = sinon.stub().callsArgWith(5, null, "OK")
+				@start = Date.now()
+				@DropboxWebhookHandler._delayAndBatchPoll @dropbox_uid, (error, @shouldPoll) =>
+					@end = Date.now()
+					done()
+					
+			it "should set the lock", ->
+				@rclient.set
+					.calledWith("dropbox-poll-lock:#{@dropbox_uid}", "LOCK", "PX", @DropboxWebhookHandler.POLL_DELAY_IN_MS, "NX")
+					.should.equal true
+			
+			it "should return the callback after the delay with shouldPoll=true", ->
+				@shouldPoll.should.equal true
+				expect(@end - @start).to.be.at.least(@DropboxWebhookHandler.POLL_DELAY_IN_MS)
+			
+		describe "when someone else is already polling", ->
+			beforeEach ->
+				@rclient.set = sinon.stub().callsArgWith(5, null, null)
+				@DropboxWebhookHandler._delayAndBatchPoll @dropbox_uid, @callback
+			
+			it "should return the callback immediately with shouldPoll=false", ->
+				@callback.calledWith(null, false).should.equal true
+				

+ 17 - 0
services/web/test/UnitTests/coffee/ThirdPartyDataStore/TpdsUpdateSenderTests.coffee

@@ -105,3 +105,20 @@ describe 'TpdsUpdateSender', ->
 				job.headers.sl_all_user_ids.should.eql(JSON.stringify([collaberator_ref_1, read_only_ref_1, user_id]))
 				done()
 			@updateSender.moveEntity {project_id:project_id, project_name:oldProjectName, newProjectName:newProjectName}
+			
+		it "pollDropboxForUser", (done) ->
+			@requestQueuer.enqueue = sinon.stub().callsArg(3)
+			@updateSender.pollDropboxForUser user_id, (error) =>
+				@requestQueuer.enqueue
+					.calledWith(
+						"poll-dropbox:#{user_id}",
+						"standardHttpRequest",
+						{
+							method: "POST"
+							uri: "#{thirdPartyDataStoreApiUrl}/user/poll"
+							json:
+								user_ids: [user_id]
+						}
+					)
+					.should.equal true
+				done()