Explorar o código

Merge branch 'master' of github.com:sharelatex/web-sharelatex

Henry Oswald %!s(int64=12) %!d(string=hai) anos
pai
achega
e7602321e9
Modificáronse 25 ficheiros con 747 adicións e 1948 borrados
  1. 1 0
      services/web/Gruntfile.coffee
  2. 1 1
      services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee
  3. 13 0
      services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee
  4. 4 0
      services/web/app/coffee/router.coffee
  5. 1 1
      services/web/app/views/about/about.jade
  6. 2 1
      services/web/app/views/project/editor.jade
  7. 6 0
      services/web/app/views/project/list.jade
  8. 31 9
      services/web/app/views/templates.jade
  9. 8 2
      services/web/public/coffee/editor/Editor.coffee
  10. 4 1
      services/web/public/coffee/ide.coffee
  11. 4 4
      services/web/public/coffee/list.coffee
  12. 32 1
      services/web/public/coffee/models/User.coffee
  13. 198 0
      services/web/public/coffee/track-changes/ChangeListView.coffee
  14. 140 0
      services/web/public/coffee/track-changes/DiffView.coffee
  15. 57 0
      services/web/public/coffee/track-changes/TrackChangesManager.coffee
  16. 12 0
      services/web/public/coffee/track-changes/models/Change.coffee
  17. 25 0
      services/web/public/coffee/track-changes/models/ChangeList.coffee
  18. 13 0
      services/web/public/coffee/track-changes/models/Diff.coffee
  19. 19 0
      services/web/public/js/libs/md5.js
  20. 5 1662
      services/web/public/js/libs/moment.js
  21. 8 30
      services/web/public/stylesheets/less/editor.less
  22. 0 236
      services/web/public/stylesheets/less/orchard.less
  23. 116 0
      services/web/public/stylesheets/less/trackchanges.less
  24. 1 0
      services/web/public/stylesheets/mainStyle.less
  25. 46 0
      services/web/test/UnitTests/coffee/TrackChanges/TrackChangesControllerTests.coffee

+ 1 - 0
services/web/Gruntfile.coffee

@@ -84,6 +84,7 @@ module.exports = (grunt) ->
 					paths:
 						"underscore": "libs/underscore"
 						"jquery": "libs/jquery"
+						"moment": "libs/moment"
 					shim:
 						"libs/backbone":
 							deps: ["libs/underscore"]

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

@@ -217,7 +217,7 @@ module.exports = ProjectEntityHandler =
 						logger.err "error putting doc #{doc_id} in project #{project_id} #{err}"
 						callback err
 					else if docComparitor.areSame docLines, doc.lines
-						logger.log sl_req_id: sl_req_id, docLines:docLines, project_id:project_id, doc_id:doc_id, rev:doc.rev, "old doc lines are same as the new doc lines, not updating them"
+						logger.log sl_req_id: sl_req_id, project_id:project_id, doc_id:doc_id, rev:doc.rev, "old doc lines are same as the new doc lines, not updating them"
 						callback()
 					else
 						logger.log sl_req_id: sl_req_id, project_id:project_id, doc_id:doc_id, docLines: docLines, oldDocLines: doc.lines, rev:doc.rev, "updating doc lines"

+ 13 - 0
services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee

@@ -0,0 +1,13 @@
+logger = require "logger-sharelatex"
+request = require "request"
+settings = require "settings-sharelatex"
+
+module.exports = TrackChangesController =
+	proxyToTrackChangesApi: (req, res, next = (error) ->) ->
+		url = settings.apis.trackchanges.url + req.url
+		logger.log url: url, "proxying to track-changes api"
+		getReq = request.get(url)
+		getReq.pipe(res)
+		getReq.on "error", (error) ->
+			logger.error err: error, "track-changes API error"
+			next(error)

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

@@ -32,6 +32,7 @@ CompileController = require("./Features/Compile/CompileController")
 HealthCheckController = require("./Features/HealthCheck/HealthCheckController")
 ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController"
 FileStoreController = require("./Features/FileStore/FileStoreController")
+TrackChangesController = require("./Features/TrackChanges/TrackChangesController")
 logger = require("logger-sharelatex")
 
 httpAuth = require('express').basicAuth (user, pass)->
@@ -122,6 +123,9 @@ module.exports = class Router
 		app.get  '/Project/:Project_id/version', SecutiryManager.requestCanAccessProject, versioningController.listVersions
 		app.get  '/Project/:Project_id/version/:Version_id', SecutiryManager.requestCanAccessProject, versioningController.getVersion
 
+		app.get  "/project/:Project_id/doc/:doc_id/updates", SecutiryManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi
+		app.get  "/project/:Project_id/doc/:doc_id/diff", SecutiryManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi
+
 		app.post '/project/:project_id/leave', AuthenticationController.requireLogin(), CollaboratorsController.removeSelfFromProject
 		app.get  '/project/:Project_id/collaborators', SecutiryManager.requestCanAccessProject(allow_auth_token: true), CollaboratorsController.getCollaborators
 

+ 1 - 1
services/web/app/views/about/about.jade

@@ -24,7 +24,7 @@ block content
 				p(style="clear: both")
 					
 				h3 Motivation
-				p Our first priority with ShareLaTeX is to build a tool which makes life easier for all the LaTeX users our there. 
+				p Our first priority with ShareLaTeX is to build a tool which makes life easier for all the LaTeX users out there. 
 					| The "thank you"s and success stories are a strong motivator for us, and we hope to always be able to offer a fully-functional 
 					| LaTeX editor which anyone can use for free.
 				p We also believe that charging money for tools like ShareLaTeX is important since it helps to guarantee the future of the site and 

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

@@ -38,7 +38,8 @@ block content
 		window.requirejs = {
 			"paths" : {
 				"underscore": "libs/underscore",
-				"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML"
+				"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML",
+				"moment": "libs/moment"
 			},
 			"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}",
 			"waitSeconds": 0,

+ 6 - 0
services/web/app/views/project/list.jade

@@ -144,6 +144,12 @@ block content
 		mixin tag('{{ project_id }}', '{{ tagName }}', true)
 
 	- locals.supressDefaultJs = true
+	script
+		window.requirejs = {
+			"paths" : {
+				"moment": "libs/moment"
+			}
+		};
 	script(
 		data-main=jsPath+'list.js?fingerprint='+fingerprint(jsPath + 'list.js'), 
 		baseurl=jsPath,

+ 31 - 9
services/web/app/views/templates.jade

@@ -147,10 +147,11 @@
 		#editorArea(style='display: none;')
 			#editorSplitter
 				#leftEditorPanel.ui-layout-center
-					#editor
-					#undoConflictWarning(style="display: none")
-						| <strong>Watch out!</strong> We had to undo some of your collaborators changes before we could undo yours.
-						a(href="#").js-hide Hide
+					#editorWrapper
+						#editor
+						#undoConflictWarning(style="display: none")
+							| <strong>Watch out!</strong> We had to undo some of your collaborators changes before we could undo yours.
+							a(href="#").js-hide Hide
 				#rightEditorPanel.ui-layout-east
 
 	script(type="text/template")#loadingIndicatorTemplate
@@ -403,11 +404,6 @@
 			div(class='version-message') {{message}}
 			div(class='version-date') {{date}} 
 
-	script(type='text/template')#autoCompleteSuggestionTemplate
-		li
-			strong {{base}}
-			| {{completion}}
-
 	script(type='text/template')#versionListTemplate
 		ul#version-list.nav.nav-pills.nav-stacked
 			li.loading Loading...
@@ -427,6 +423,32 @@
 		div
 			a(href="#", title='Show Hot Keys List') Hot keys
 
+	script(type='text/template')#trackChangesPanelTemplate
+		#trackChangesPanel
+			.track-changes-side-bar
+				.track-changes-header
+					h3 Recent changes
+					a(href="#").track-changes-close
+						i.icon-remove
+				.change-list-area
+			.track-changes-diff
+
+	script(type='text/template')#changeListItemTemplate
+		div(class='change-selectors')
+			input(type="radio",name="fromVersion").change-selector-from
+			input(type="radio",name="toVersion").change-selector-to
+
+		div(class='change-description')
+			div(class='change-date') {{date}} 
+			div(class='change-name')
+				div.color-square(style="background-color: hsl({{hue}}, 100%, 70%);")
+				span {{name}}
+
+	script(type='text/template')#changeListTemplate
+		ul.change-list.nav.nav-pills.nav-stacked
+			li.loading-changes Loading...
+			li.empty-message You haven't made any changes yet!
+
 	script(type='text/template')#hotKeysListTemplate
 		.hotkeys
 			h3 Common

+ 8 - 2
services/web/public/coffee/editor/Editor.coffee

@@ -73,7 +73,7 @@ define [
 			if @currentViewState != @viewOptions.splitView
 				@currentViewState = @viewOptions.splitView
 				@leftPanel.prepend(
-					@editorPanel.find("#editor")
+					@editorPanel.find("#editorWrapper")
 				)
 				splitter = @editorPanel.find("#editorSplitter")
 				splitter.show()
@@ -84,7 +84,7 @@ define [
 				@_saveSplitterState()
 				@currentViewState = @viewOptions.flatView
 				@editorPanel.prepend(
-					@editorPanel.find("#editor")
+					@editorPanel.find("#editorWrapper")
 				)
 				@editorPanel.find("#editorSplitter").hide()
 				@aceEditor.resize(true)
@@ -352,3 +352,9 @@ define [
 		getCurrentDocId: () ->
 			@current_doc_id
 
+		show: () ->
+			$("#editor").show()
+
+		hide: () ->
+			$("#editor").hide()
+

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

@@ -25,6 +25,7 @@ define [
 	"file-view/FileViewManager"
 	"tour/IdeTour"
 	"analytics/AnalyticsManager"
+	"track-changes/TrackChangesManager"
 	"ace/ace"
 	"libs/jquery.color"
 	"libs/jquery-layout"
@@ -56,7 +57,8 @@ define [
 	BackspaceHighjack,
 	FileViewManager,
 	IdeTour,
-	AnalyticsManager
+	AnalyticsManager,
+	TrackChangesManager
 ) ->
 
 
@@ -117,6 +119,7 @@ define [
 			@cursorManager = new CursorManager(@)
 			@fileViewManager = new FileViewManager(@)
 			@analyticsManager = new AnalyticsManager(@)
+			@trackChangesManager = new TrackChangesManager(@)
 
 			@setLoadingMessage("Connecting")
 			firstConnect = true

+ 4 - 4
services/web/public/coffee/list.coffee

@@ -1,16 +1,16 @@
 require [
 	"tags"
+	"moment"
 	"gui"
-	"libs/moment"
 	"libs/underscore"
 	"libs/fineuploader"
 	"libs/jquery.storage"
-], (tagsManager)->
+], (tagsManager, moment)->
 
 	$('.isoDate').each (i, d)->
 		html = $(d)
-		unparsedDate = html.text()
-		formatedDate = moment(unparsedDate).format('LLL')
+		unparsedDate = html.text().trim()
+		formatedDate = moment(unparsedDate).format("Do MMM YYYY, h:mm:ss a")
 		html.text(formatedDate)
 
 	refreshProjectFilter = ->

+ 32 - 1
services/web/public/coffee/models/User.coffee

@@ -1,7 +1,38 @@
 define [
+	"libs/md5"
 	"libs/backbone"
 ], () ->
-	User = Backbone.Model.extend {}, {
+	User = Backbone.Model.extend {
+		gravatarUrl: (size = 32) ->
+			email = @get("email").trim().toLowerCase()
+			hash = CryptoJS.MD5(email)
+			return "//www.gravatar.com/avatar/#{hash}.jpg?size=#{size}&d=mm"
+
+		OWNER_HUE: 200
+		hue: () ->
+			if window.user.id == @get("id")
+				hue = @OWNER_HUE
+			else
+				hash = CryptoJS.MD5(@get("id"))
+				hue = parseInt(hash.toString().slice(0,8), 16) % 320
+				# Avoid 20 degrees either side of the owner
+				if hue > @OWNER_HUE - 20
+					hue = hue + 40
+			return hue
+
+		name: () ->
+			if window.user.id == @get("id")
+				return "you"
+			parts = []
+			first_name = @get("first_name")
+			if first_name? and first_name.length > 0
+				parts.push first_name
+			last_name = @get("last_name")
+			if last_name? and last_name.length > 0
+				parts.push last_name
+			return parts.join(" ")
+
+	}, {
 		findOrBuild : (id, attributes) ->
 			model = @find id
 			if !model?

+ 198 - 0
services/web/public/coffee/track-changes/ChangeListView.coffee

@@ -0,0 +1,198 @@
+define [
+	"moment"
+	"libs/mustache"
+	"libs/backbone"
+], (moment)->
+	ChangeListView = Backbone.View.extend
+		template: $("#changeListTemplate").html()
+
+		events:
+			"scroll" : "loadUntilFull"
+
+		initialize: () ->
+			@itemViews = []
+			@atEndOfCollection = false
+
+			self = this
+			@collection.on "add", (model) ->
+				self.addItem model
+			@collection.on "reset", (collection) ->
+				self.addItem model for model in collection.models
+
+			@selectedFromIndex = 0
+			@selectedToIndex = 0
+
+			@render()
+			@hideLoading()
+
+		render: ->
+			@$el.html Mustache.to_html @template
+			@$el.css
+				overflow: "scroll"
+			this
+
+		addItem: (model) ->
+			index = @collection.indexOf(model)
+			view = new ChangeListItemView(model : model)
+			@itemViews.push view
+			elementAtIndex = @$(".change-list").children()[index]
+			view.$el.insertBefore(elementAtIndex)
+
+			view.on "click", (e, v) =>
+				@selectedToIndex = index
+				@selectedFromIndex = index
+				@resetAllSelectors()
+				@triggerChangeDiff()
+
+			view.on "selected:to", (e, v) =>
+				@selectedToIndex = index
+				@resetAllSelectors()
+				@triggerChangeDiff()
+
+			view.on "selected:from", (e, v) =>
+				@selectedFromIndex = index
+				@resetAllSelectors()
+				@triggerChangeDiff()
+
+			view.resetSelector(index, @selectedFromIndex, @selectedToIndex)
+
+		resetAllSelectors: () ->
+			for view, i in @itemViews
+				view.resetSelector(i, @selectedFromIndex, @selectedToIndex)
+
+		triggerChangeDiff: () ->
+			@trigger "change_diff", @collection.models[@selectedFromIndex], @collection.models[@selectedToIndex]
+
+		listShorterThanContainer: ->
+			@$el.height() > @$(".change-list").height()
+		
+		atEndOfListView: ->
+			@$el.scrollTop() + @$el.height() >= @$(".change-list").height() - 30
+
+		loadUntilFull: (e, callback) ->
+			if (@listShorterThanContainer() or @atEndOfListView()) and not @atEndOfCollection and not @loading
+				@showLoading()
+				@hideEmptyMessage()
+				@collection.fetchNextBatch
+					error: =>
+						@hideLoading()
+						@showEmptyMessageIfCollectionEmpty()
+						callback() if callback?
+					success: (collection, response) =>
+						@hideLoading()
+						if response.updates.length == @collection.batchSize
+							@loadUntilFull(e, callback)
+						else
+							@atEndOfCollection = true
+							@showEmptyMessageIfCollectionEmpty()
+							callback() if callback?
+							
+			else
+				callback() if callback?
+
+		showEmptyMessageIfCollectionEmpty: ()->
+			if @collection.isEmpty()
+				@$(".empty-message").show()
+			else
+				@$(".empty-message").hide()
+
+		hideEmptyMessage: () ->
+			@$(".empty-message").hide()
+
+		showLoading: ->
+			@loading = true
+			@$(".loading-changes").show()
+		
+		hideLoading: ->
+			@loading = false
+			@$(".loading-changes").hide()
+	
+	ChangeListItemView = Backbone.View.extend
+		tagName: "li"
+
+		events:
+			"click .change-description"   : "onClick"
+			"click .change-selector-from" : "onFromSelectorClick"
+			"click .change-selector-to"   : "onToSelectorClick"
+	
+		template : $("#changeListItemTemplate").html()
+
+		initialize: ->
+			@render()
+
+		render: ->
+			@$el.html Mustache.to_html(@template, @modelView())
+			return this
+		
+		modelView: ->
+			modelView = {
+				hue:  @model.get("user").hue()
+				date: moment(parseInt(@model.get("end_ts"), 10)).calendar()
+				name: @model.get("user").name()
+			}
+			# modelView.start_ts = util.formatDate(modelView.start_ts)
+			# modelView.end_ts = util.formatDate(modelView.end_ts)
+			return modelView
+
+		onClick: (e) ->
+			e.preventDefault()
+			@trigger "click", e, @
+
+		onToSelectorClick: (e) ->
+			@trigger "selected:to", e, @
+
+		onFromSelectorClick: (e) ->
+			@trigger "selected:from", e, @
+
+		isSelectedFrom: () ->
+			@$(".change-selector-from").is(":checked")
+
+		isSelectedTo: () ->
+			@$(".change-selector-to").is(":checked")
+
+		hideFromSelector: () ->
+			@$(".change-selector-from").hide()
+
+		showFromSelector: () ->
+			@$(".change-selector-from").show()
+
+		hideToSelector: () ->
+			@$(".change-selector-to").hide()
+
+		showToSelector: () ->
+			@$(".change-selector-to").show()
+
+		setFromChecked: (checked) ->
+			@$(".change-selector-from").prop("checked", checked)
+
+		setToChecked: (checked) ->
+			@$(".change-selector-to").prop("checked", checked)
+
+		setSelected: () ->
+			@$el.addClass("selected")
+
+		setUnselected: () ->
+			@$el.removeClass("selected")
+
+		resetSelector: (myIndex, selectedFromIndex, selectedToIndex) ->
+			if myIndex >= selectedToIndex
+				@showFromSelector()
+			else
+				@hideFromSelector()
+
+			if myIndex <= selectedFromIndex
+				@showToSelector()
+			else
+				@hideToSelector()
+
+			if selectedToIndex <= myIndex <= selectedFromIndex
+				@setSelected()
+			else
+				@setUnselected()
+
+			@setFromChecked(myIndex == selectedFromIndex)
+			@setToChecked(myIndex == selectedToIndex)
+
+
+	return ChangeListView
+

+ 140 - 0
services/web/public/coffee/track-changes/DiffView.coffee

@@ -0,0 +1,140 @@
+define [
+	"ace/ace"
+	"ace/mode/latex"
+	"ace/range"
+	"libs/backbone"
+], (Ace, LatexMode, Range)->
+	DiffView = Backbone.View.extend
+		initialize: () ->
+			@model.on "change:diff", () => @render()
+			@render()
+
+		render: ->
+			diff = @model.get("diff")
+			return unless diff?
+			@createAceEditor()
+			@aceEditor.setValue(@getPlainDiffContent())
+			@aceEditor.clearSelection()
+			session = @aceEditor.getSession()
+			session.setMode(new LatexMode.Mode())
+			session.setUseWrapMode(true)
+			@insertMarkers()
+			return @
+
+		createAceEditor: () ->
+			@$el.empty()
+			$editor = $("<div/>")
+			@$el.append($editor)
+			@aceEditor = Ace.edit($editor[0])
+			@aceEditor.setTheme("ace/theme/#{window.userSettings.theme}")
+			@aceEditor.setReadOnly true
+			@aceEditor.setShowPrintMargin(false)
+
+			@aceEditor.on "mousemove", (e) =>
+				position = @aceEditor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
+				e.position = position
+				@updateVisibleNames(e)
+
+		getPlainDiffContent: () ->
+			content = ""
+			for entry in @model.get("diff") or []
+				content += entry.u or entry.i or entry.d or ""
+			return content
+
+		insertMarkers: () ->
+			row    = 0
+			column = 0
+			for entry, i in @model.get("diff") or []
+				content = entry.u or entry.i or entry.d
+				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 = new Range.Range(
+					startRow, startColumn, endRow, endColumn
+				)
+				@addMarker(range, "change-marker-#{i}", entry)
+
+		addMarker: (range, id, entry) ->
+			session  = @aceEditor.getSession()
+			markerBackLayer = @aceEditor.renderer.$markerBack
+			markerFrontLayer = @aceEditor.renderer.$markerFront
+			lineHeight = @aceEditor.renderer.lineHeight
+			if entry.i? or entry.d?
+				hue = entry.meta.user.hue()
+				if entry.i?
+					@_addMarkerWithCustomStyle session, markerBackLayer, range, "deleted-change-background", false, """
+						background-color : hsl(#{hue}, 70%, 85%);
+					"""
+					tag = "Added by #{entry.meta.user.name()}"
+				if entry.d?
+					@_addMarkerWithCustomStyle session, markerBackLayer, range, "deleted-change-background", false, """
+						background-color : hsl(#{hue}, 70%, 95%);
+					"""
+					@_addMarkerWithCustomStyle session, markerBackLayer, range, "deleted-change-foreground", true, """
+						height: #{Math.round(lineHeight/2) - 1}px;
+						border-bottom: 2px solid hsl(#{hue}, 70%, 40%);
+					"""
+					tag = "Deleted by #{entry.meta.user.name()}"
+
+				date = moment(parseInt(entry.meta.end_ts, 10)).format("Do MMM YYYY, h:mm:ss a")
+				tag += " on #{date}"
+				@_addNameTag session, id, range, tag, """
+					background-color : hsl(#{hue}, 70%, 95%);
+				"""
+
+		_addMarkerWithCustomStyle: (session, markerLayer, range, klass, foreground, style) ->
+			session.addMarker range, klass, (html, range, left, top, config) ->
+				if range.isMultiLine()
+					markerLayer.drawTextMarker(html, range, klass, config, style)
+				else
+					markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
+			, foreground
+
+		_addNameTag: (session, id, range, content, style) ->
+			@nameMarkers ||= []
+			@nameMarkers.push
+				range: range
+				id: id
+			startRange = new Range.Range(
+				range.start.row, range.start.column
+				range.start.row, range.start.column + 1
+			)
+			session.addMarker startRange, "change-name-marker", (html, range, left, top, config) ->
+				html.push """
+					<div
+						id    = '#{id}'
+						class = 'change-name-marker'
+						style = '
+							height: #{config.lineHeight}px;
+							top:    #{top}px;
+							left:   #{left}px;
+						'
+					>
+						<div
+							class="name" style="
+								display: none;
+								bottom: #{config.lineHeight + 3}px;
+								#{style}
+							">#{content}</div>
+					</div>
+				"""
+			, true
+
+		updateVisibleNames: (e) ->
+			for marker in @nameMarkers or []
+				if marker.range.contains(e.position.row, e.position.column)
+					$("##{marker.id}").find(".name").show()
+				else
+					$("##{marker.id}").find(".name").hide()
+
+	return DiffView
+

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

@@ -0,0 +1,57 @@
+define [
+	"track-changes/models/ChangeList"
+	"track-changes/models/Diff"
+	"track-changes/ChangeListView"
+	"track-changes/DiffView"
+], (ChangeList, Diff, ChangeListView, DiffView) ->
+	class TrackChangesManager
+		template: $("#trackChangesPanelTemplate").html()
+		
+		constructor: (@ide) ->
+			@$el = $(@template)
+			$("#editorWrapper").append(@$el)
+			@hideEl()
+
+			@ide.editor.on "change:doc", () =>
+				@hideEl()
+
+			@$el.find(".track-changes-close").on "click", (e) =>
+				e.preventDefault
+				@hideEl()
+
+		show: () ->
+			@project_id = window.userSettings.project_id
+			@doc_id = @ide.editor.current_doc_id
+			@changes = new ChangeList([], doc_id: @doc_id, project_id: @project_id)
+
+			@changeListView = new ChangeListView(
+				collection : @changes,
+				el         : @$el.find(".change-list-area")
+			)
+			@changeListView.render()
+			@changeListView.loadUntilFull()
+
+			@changeListView.on "change_diff", (fromModel, toModel) =>
+				@diff = new Diff({
+					project_id: @project_id
+					doc_id: @doc_id
+					from: fromModel.get("version")
+					to:   toModel.get("version")
+				})
+				@diffView = new DiffView(
+					model: @diff
+					el:    @$el.find(".track-changes-diff")
+				)
+				@diff.fetch()
+
+			@showEl()
+
+		showEl: ->
+			@ide.editor.hide()
+			@$el.show()
+
+		hideEl: () ->
+			@ide.editor.show()
+			@$el.hide()
+
+	return TrackChangesManager

+ 12 - 0
services/web/public/coffee/track-changes/models/Change.coffee

@@ -0,0 +1,12 @@
+define [
+	"models/User"
+	"libs/backbone"
+], (User)->
+	Change = Backbone.Model.extend
+		parse: (change) ->
+			return {
+				start_ts: change.meta.start_ts
+				end_ts: change.meta.end_ts
+				user: User.findOrBuild(change.meta.user.id, change.meta.user)
+				version: change.v
+			}

+ 25 - 0
services/web/public/coffee/track-changes/models/ChangeList.coffee

@@ -0,0 +1,25 @@
+define [
+	"track-changes/models/Change"
+	"libs/backbone"
+], (Change)->
+	ChangeList = Backbone.Collection.extend
+		model: Change
+		batchSize: 25
+
+		initialize: (models, @options) ->
+
+		url: () ->
+			url = "/project/#{@options.project_id}/doc/#{@options.doc_id}/updates?limit=#{@batchSize}"
+			if @models.length > 0
+				last = @models[@models.length - 1]
+				url += "&to=#{last.get("version") - 1}"
+			return url
+
+		parse: (json) ->
+			return json.updates
+
+		fetchNextBatch: (options = {}) ->
+			options.add = true
+			@fetch options
+
+			

+ 13 - 0
services/web/public/coffee/track-changes/models/Diff.coffee

@@ -0,0 +1,13 @@
+define [
+	"models/User"
+	"libs/backbone"
+], (User) ->
+	Diff = Backbone.Model.extend
+		url: () ->
+			"/project/#{@get("project_id")}/doc/#{@get("doc_id")}/diff?from=#{@get("from")}&to=#{@get("to")}"
+
+		parse: (diff) ->
+			for entry in diff.diff
+				if entry.meta? and entry.meta.user?
+					entry.meta.user = User.findOrBuild(entry.meta.user.id, entry.meta.user)
+			return diff

+ 19 - 0
services/web/public/js/libs/md5.js

@@ -0,0 +1,19 @@
+/*
+CryptoJS v3.1.2
+code.google.com/p/crypto-js
+(c) 2009-2013 by Jeff Mott. All rights reserved.
+code.google.com/p/crypto-js/wiki/License
+*/
+var CryptoJS=CryptoJS||function(s,p){var m={},l=m.lib={},n=function(){},r=l.Base={extend:function(b){n.prototype=this;var h=new n;b&&h.mixIn(b);h.hasOwnProperty("init")||(h.init=function(){h.$super.init.apply(this,arguments)});h.init.prototype=h;h.$super=this;return h},create:function(){var b=this.extend();b.init.apply(b,arguments);return b},init:function(){},mixIn:function(b){for(var h in b)b.hasOwnProperty(h)&&(this[h]=b[h]);b.hasOwnProperty("toString")&&(this.toString=b.toString)},clone:function(){return this.init.prototype.extend(this)}},
+q=l.WordArray=r.extend({init:function(b,h){b=this.words=b||[];this.sigBytes=h!=p?h:4*b.length},toString:function(b){return(b||t).stringify(this)},concat:function(b){var h=this.words,a=b.words,j=this.sigBytes;b=b.sigBytes;this.clamp();if(j%4)for(var g=0;g<b;g++)h[j+g>>>2]|=(a[g>>>2]>>>24-8*(g%4)&255)<<24-8*((j+g)%4);else if(65535<a.length)for(g=0;g<b;g+=4)h[j+g>>>2]=a[g>>>2];else h.push.apply(h,a);this.sigBytes+=b;return this},clamp:function(){var b=this.words,h=this.sigBytes;b[h>>>2]&=4294967295<<
+32-8*(h%4);b.length=s.ceil(h/4)},clone:function(){var b=r.clone.call(this);b.words=this.words.slice(0);return b},random:function(b){for(var h=[],a=0;a<b;a+=4)h.push(4294967296*s.random()|0);return new q.init(h,b)}}),v=m.enc={},t=v.Hex={stringify:function(b){var a=b.words;b=b.sigBytes;for(var g=[],j=0;j<b;j++){var k=a[j>>>2]>>>24-8*(j%4)&255;g.push((k>>>4).toString(16));g.push((k&15).toString(16))}return g.join("")},parse:function(b){for(var a=b.length,g=[],j=0;j<a;j+=2)g[j>>>3]|=parseInt(b.substr(j,
+2),16)<<24-4*(j%8);return new q.init(g,a/2)}},a=v.Latin1={stringify:function(b){var a=b.words;b=b.sigBytes;for(var g=[],j=0;j<b;j++)g.push(String.fromCharCode(a[j>>>2]>>>24-8*(j%4)&255));return g.join("")},parse:function(b){for(var a=b.length,g=[],j=0;j<a;j++)g[j>>>2]|=(b.charCodeAt(j)&255)<<24-8*(j%4);return new q.init(g,a)}},u=v.Utf8={stringify:function(b){try{return decodeURIComponent(escape(a.stringify(b)))}catch(g){throw Error("Malformed UTF-8 data");}},parse:function(b){return a.parse(unescape(encodeURIComponent(b)))}},
+g=l.BufferedBlockAlgorithm=r.extend({reset:function(){this._data=new q.init;this._nDataBytes=0},_append:function(b){"string"==typeof b&&(b=u.parse(b));this._data.concat(b);this._nDataBytes+=b.sigBytes},_process:function(b){var a=this._data,g=a.words,j=a.sigBytes,k=this.blockSize,m=j/(4*k),m=b?s.ceil(m):s.max((m|0)-this._minBufferSize,0);b=m*k;j=s.min(4*b,j);if(b){for(var l=0;l<b;l+=k)this._doProcessBlock(g,l);l=g.splice(0,b);a.sigBytes-=j}return new q.init(l,j)},clone:function(){var b=r.clone.call(this);
+b._data=this._data.clone();return b},_minBufferSize:0});l.Hasher=g.extend({cfg:r.extend(),init:function(b){this.cfg=this.cfg.extend(b);this.reset()},reset:function(){g.reset.call(this);this._doReset()},update:function(b){this._append(b);this._process();return this},finalize:function(b){b&&this._append(b);return this._doFinalize()},blockSize:16,_createHelper:function(b){return function(a,g){return(new b.init(g)).finalize(a)}},_createHmacHelper:function(b){return function(a,g){return(new k.HMAC.init(b,
+g)).finalize(a)}}});var k=m.algo={};return m}(Math);
+(function(s){function p(a,k,b,h,l,j,m){a=a+(k&b|~k&h)+l+m;return(a<<j|a>>>32-j)+k}function m(a,k,b,h,l,j,m){a=a+(k&h|b&~h)+l+m;return(a<<j|a>>>32-j)+k}function l(a,k,b,h,l,j,m){a=a+(k^b^h)+l+m;return(a<<j|a>>>32-j)+k}function n(a,k,b,h,l,j,m){a=a+(b^(k|~h))+l+m;return(a<<j|a>>>32-j)+k}for(var r=CryptoJS,q=r.lib,v=q.WordArray,t=q.Hasher,q=r.algo,a=[],u=0;64>u;u++)a[u]=4294967296*s.abs(s.sin(u+1))|0;q=q.MD5=t.extend({_doReset:function(){this._hash=new v.init([1732584193,4023233417,2562383102,271733878])},
+_doProcessBlock:function(g,k){for(var b=0;16>b;b++){var h=k+b,w=g[h];g[h]=(w<<8|w>>>24)&16711935|(w<<24|w>>>8)&4278255360}var b=this._hash.words,h=g[k+0],w=g[k+1],j=g[k+2],q=g[k+3],r=g[k+4],s=g[k+5],t=g[k+6],u=g[k+7],v=g[k+8],x=g[k+9],y=g[k+10],z=g[k+11],A=g[k+12],B=g[k+13],C=g[k+14],D=g[k+15],c=b[0],d=b[1],e=b[2],f=b[3],c=p(c,d,e,f,h,7,a[0]),f=p(f,c,d,e,w,12,a[1]),e=p(e,f,c,d,j,17,a[2]),d=p(d,e,f,c,q,22,a[3]),c=p(c,d,e,f,r,7,a[4]),f=p(f,c,d,e,s,12,a[5]),e=p(e,f,c,d,t,17,a[6]),d=p(d,e,f,c,u,22,a[7]),
+c=p(c,d,e,f,v,7,a[8]),f=p(f,c,d,e,x,12,a[9]),e=p(e,f,c,d,y,17,a[10]),d=p(d,e,f,c,z,22,a[11]),c=p(c,d,e,f,A,7,a[12]),f=p(f,c,d,e,B,12,a[13]),e=p(e,f,c,d,C,17,a[14]),d=p(d,e,f,c,D,22,a[15]),c=m(c,d,e,f,w,5,a[16]),f=m(f,c,d,e,t,9,a[17]),e=m(e,f,c,d,z,14,a[18]),d=m(d,e,f,c,h,20,a[19]),c=m(c,d,e,f,s,5,a[20]),f=m(f,c,d,e,y,9,a[21]),e=m(e,f,c,d,D,14,a[22]),d=m(d,e,f,c,r,20,a[23]),c=m(c,d,e,f,x,5,a[24]),f=m(f,c,d,e,C,9,a[25]),e=m(e,f,c,d,q,14,a[26]),d=m(d,e,f,c,v,20,a[27]),c=m(c,d,e,f,B,5,a[28]),f=m(f,c,
+d,e,j,9,a[29]),e=m(e,f,c,d,u,14,a[30]),d=m(d,e,f,c,A,20,a[31]),c=l(c,d,e,f,s,4,a[32]),f=l(f,c,d,e,v,11,a[33]),e=l(e,f,c,d,z,16,a[34]),d=l(d,e,f,c,C,23,a[35]),c=l(c,d,e,f,w,4,a[36]),f=l(f,c,d,e,r,11,a[37]),e=l(e,f,c,d,u,16,a[38]),d=l(d,e,f,c,y,23,a[39]),c=l(c,d,e,f,B,4,a[40]),f=l(f,c,d,e,h,11,a[41]),e=l(e,f,c,d,q,16,a[42]),d=l(d,e,f,c,t,23,a[43]),c=l(c,d,e,f,x,4,a[44]),f=l(f,c,d,e,A,11,a[45]),e=l(e,f,c,d,D,16,a[46]),d=l(d,e,f,c,j,23,a[47]),c=n(c,d,e,f,h,6,a[48]),f=n(f,c,d,e,u,10,a[49]),e=n(e,f,c,d,
+C,15,a[50]),d=n(d,e,f,c,s,21,a[51]),c=n(c,d,e,f,A,6,a[52]),f=n(f,c,d,e,q,10,a[53]),e=n(e,f,c,d,y,15,a[54]),d=n(d,e,f,c,w,21,a[55]),c=n(c,d,e,f,v,6,a[56]),f=n(f,c,d,e,D,10,a[57]),e=n(e,f,c,d,t,15,a[58]),d=n(d,e,f,c,B,21,a[59]),c=n(c,d,e,f,r,6,a[60]),f=n(f,c,d,e,z,10,a[61]),e=n(e,f,c,d,j,15,a[62]),d=n(d,e,f,c,x,21,a[63]);b[0]=b[0]+c|0;b[1]=b[1]+d|0;b[2]=b[2]+e|0;b[3]=b[3]+f|0},_doFinalize:function(){var a=this._data,k=a.words,b=8*this._nDataBytes,h=8*a.sigBytes;k[h>>>5]|=128<<24-h%32;var l=s.floor(b/
+4294967296);k[(h+64>>>9<<4)+15]=(l<<8|l>>>24)&16711935|(l<<24|l>>>8)&4278255360;k[(h+64>>>9<<4)+14]=(b<<8|b>>>24)&16711935|(b<<24|b>>>8)&4278255360;a.sigBytes=4*(k.length+1);this._process();a=this._hash;k=a.words;for(b=0;4>b;b++)h=k[b],k[b]=(h<<8|h>>>24)&16711935|(h<<24|h>>>8)&4278255360;return a},clone:function(){var a=t.clone.call(this);a._hash=this._hash.clone();return a}});r.MD5=t._createHelper(q);r.HmacMD5=t._createHmacHelper(q)})(Math);

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 5 - 1662
services/web/public/js/libs/moment.js


+ 8 - 30
services/web/public/stylesheets/less/editor.less

@@ -177,25 +177,13 @@ body.editor {
 			width: 100%;
 			height: 100%;
 		}
-		#editor, #orchard {
+		#editor {
 			width: 100%;
 			height: 100%;
 		}
-		#orchard-toolbar {
-			height: 28px;
-			padding: 5px;
-			background-color: white;
-			border-bottom: 1px solid #aaa;
-			select {
-				margin-bottom: 0;
-			}
-		}
-		#orchard-editor {
-			position: absolute;
-			top: 39px;
-			bottom: 0;
-			left: 0;
-			right: 0;
+		#editorWrapper {
+			height: 100%;
+			width: 100%;
 		}
 		#rightEditorPanel {
 			border-left: 1px solid #aaa;
@@ -212,6 +200,10 @@ body.editor {
 			bottom: 0;
 			z-index: 100;
 		}
+		#trackChangesPanel {
+			width: 100%;
+			height: 100%;
+		}
 	}
 
 	#undoConflictWarning {
@@ -964,18 +956,6 @@ i[class*="sprite-"] {
   background-color: #9a2b1f \9;
 }
 
-#orchard-editor {
-	background: white;
-	> .CodeMirror > .CodeMirror-scroll > .CodeMirror-sizer > div > .CodeMirror-lines {
-		background-color: white;
-		margin: auto;
-		max-width: 390pt;
-		min-height: 592pt;
-		margin-top: 20px;
-		margin-bottom: 20px;
-	}
-}
-
 #errorMessages {
 	z-index: 10000;
 	top: 4px;
@@ -998,5 +978,3 @@ i[class*="sprite-"] {
 		margin-bottom: 6px;
 	}
 }
-
-@import "./orchard";

+ 0 - 236
services/web/public/stylesheets/less/orchard.less

@@ -1,236 +0,0 @@
-.editor-font {
-	font-size: 11pt;
-	line-height: 1.4em;
-	font-family: "Computer Modern", serif;
-	font-weight: normal;
-}
-@font-face {
-	font-family: "Computer Modern";
-	src: url('../font/cmunrm.otf');
-}
-@font-face {
-	font-family: "Computer Modern";
-	src: url('../font/cmunrb.otf');
-	font-weight: bold;
-}
-@font-face {
-	font-family: "Computer Modern Monospace";
-	src: url('../font/cmuntt.otf');
-	font-weight: bold;
-}
-
-
-.CodeMirror {
-	.CodeMirror-placeholder {
-		color: #999;
-	}
-	height: auto;
-	background: none;
-	.editor-font;
-
-	.cm-command, .cm-bracket {
-		color: green;
-	}
-	.cm-math {
-		color: purple;
-		font-family: "Computer Modern Monospace", monospace;
-	}
-
-	.CodeMirror-lines {
-		padding: 10px;
-	}
-	.oe-chapter {
-		font-weight: bold;
-		font-size: 1.8em !important;
-		line-height: 2em !important;
-	}
-	.oe-section {
-		font-weight: bold;
-		font-size: 1.5em !important;
-		line-height: 2em !important;
-	}
-	.oe-subsection {
-		font-weight: bold;
-		font-size: 1.3em !important;
-		line-height: 2em !important;
-	}
-	.oe-widget-line {
-		display: none;
-	}
-	.oe-figure {
-		margin: 0 5px;
-		padding: 12px 0 4px 0;
-		text-align: center;
-		position: relative;
-		.ui-wrapper {
-			position: relative;
-			display: inline-block;
-		}
-		.oe-figure-image-wrapper {
-			display: inline-block;
-		}
-		.oe-figure-image-placeholder {
-			background-color: rgb(200,200,200);
-		}
-		.oe-figure-image-placeholder.droppable-hover {
-			background-color: rgb(200,200,255);
-		}
-		.oe-figure-toolbar {
-			display: none;
-			text-align: right;
-			padding: 4px;
-			position: absolute;
-			top: 0;
-			left: 0;
-			right: 0;
-		}
-	}
-
-	.oe-title {
-		.CodeMirror-lines {
-			padding: 0;
-		}
-		.oe-title-title {
-			text-align: center;
-			margin-top: 40px;
-			margin-bottom: 20px;
-			.CodeMirror {
-				font-size: 1.6em;
-				line-height: 1.3em;
-			}
-		}
-
-		.oe-title-author {
-			text-align: center;
-			margin-bottom: 10px;
-			.CodeMirror {
-				font-size: 1.2em;
-				line-height: 1.3em;
-			}
-		}
-
-		.oe-title-abstract {
-			margin-top: 40px;
-			margin-bottom: 30px;
-			.oe-title-abstract-label {
-				text-align: center;
-				font-weight: bold;
-				margin-bottom: 6px;
-				font-size: 0.9em;
-				line-height: 1.3em;
-			}
-
-			.oe-title-abstract-content {
-				width: 80%;
-				margin: auto;
-				.CodeMirror {
-					font-size: 0.9em;
-					line-height: 1.3em;
-					
-				}
-			}
-		}
-	}
-
-	
-	.oe-widget {
-		border: 1px solid transparent;
-		margin: 6px 0;
-	}
-	.oe-widget-selected {
-		border: 1px dashed #bbb;
-		.oe-figure-toolbar {
-			display: block;
-		}
-		.ui-wrapper {
-			border: 1px dashed gray;
-		}
-	}
-
-	.oe-block-math {
-		.MathJax_Display {
-			margin: 12px 0;
-		}
-
-		.oe-block-math-toolbar {
-			text-align: right;
-			padding: 4px;
-			z-index: 100;
-			.bootstrap-select.btn-group {
-				margin: 0;
-			}
-		}
-		.CodeMirror {
-			font-family: "Computer Modern Monospace", monospace;
-			text-align: center;
-		}
-	}
-	.oe-non-editable {
-		color: gray;
-		cursor: default;
-	}
-
-	.oe-bibliography {
-		padding: 4px;
-		cursor: pointer;
-	}
-}
-
-
-// These are needed by the citation drop down too, so
-// don't wrap them in .oe-bibliography
-.oe-bibliography-entry {
-	display: inline-block;
-}
-
-.oe-bibliography-year {
-	font-weight: bold;
-}
-.oe-bibliography-author {
-	font-weight: bold;
-}
-.oe-bibliography-title {
-	display: block;
-	margin-left: 10px;
-}
-
-.oe-bibliography {
-	.oe-bibliography-header {
-		font-weight: bold;
-		font-size: 1.8em !important;
-		line-height: 2em !important;
-	}
-
-	.oe-bibliography-entry-wrapper {
-		margin-top: 6px;
-	}
-
-	.CodeMirror {
-		font-family: "Computer Modern Monospace", monospace;
-		color: green;
-		height: 400px;
-	}
-
-	.oe-bibliography-no-entries {
-		font-style: italic;
-	}
-
-	.oe-bibliography-error {
-		color: red;
-	}
-}
-
-
-.bootstrap-select.oe-inline-select {
-	margin: 0;
-	vertical-align: bottom;
-	> .btn {
-		border: none;
-		background: none;
-		box-shadow: none;
-		padding: 0;
-		margin: 0;
-		.editor-font;
-		color: rgb(2, 151, 172);
-	}
-}

+ 116 - 0
services/web/public/stylesheets/less/trackchanges.less

@@ -0,0 +1,116 @@
+@changesListWidth:   250px;
+@changesListPadding: 10px;
+
+#trackChangesPanel {
+	.track-changes-diff {
+		position: absolute;
+		right: @changesListWidth + 1px;
+		left: 0;
+		top: 0;
+		bottom: 0;
+		padding: 0px 12px;
+		height: 100%;
+		.ace_editor {
+			position: absolute;
+			top: 0;
+			left: 0;
+			right: 0;
+			bottom: 0;
+		}
+	}
+
+	.track-changes-side-bar {
+		border-left: 1px solid #999;
+		height: 100%;
+		width: @changesListWidth;
+		position: absolute;
+		right: 0;
+		background-color: white;
+
+		.track-changes-header {
+			background-color: black;
+			h3 {
+				color: white;
+				padding-left: 8px;
+				font-size: 1.2em;
+			}
+			a {
+				color: white;
+				position: absolute;
+				top: 6px;
+				right: 8px;
+			}
+			height: 30px;
+		}
+
+		.change-list-area {
+			overflow: scroll;
+			position: absolute;
+			left: 0px;
+			right: 0px;
+			top: 30px;
+			bottom: 0px;
+		}
+	}
+
+	.deleted-change-background, .deleted-change-foreground, .inserted-change, .change-name-marker {
+		position: absolute;
+		z-index: 2;
+	}
+
+	.change-name-marker {
+		.name {
+			font-size: 0.8em;
+			padding: 2px 6px;
+			.border-radius(3px 3px 3px 3px);
+			position: absolute;
+			border: 1px solid #999;
+			left: 0;
+		}
+	}
+
+	ul.change-list {
+		li {
+			padding: 6px 4px;
+			position: relative;
+			border-bottom: 1px solid #ccc;
+			cursor: pointer;
+			.change-selectors {
+				position: absolute;
+				top: 4px;
+				left: 6px;
+				.change-selector-from {
+					position: absolute;
+					top: 0;
+					left: 0;
+				}
+				.change-selector-to {
+					position: absolute;
+					top: 0;
+					left: 20px;
+				}
+			}
+			.change-description {
+				padding-left: 42px;
+			}
+			.change-name {
+				font-size: 0.9em;
+				color: #666;
+				text-transform: capitalize;
+			}
+			.color-square {
+				display: inline-block;
+				height: 10px;
+				width: 10px;
+				margin-right: 4px;
+				margin-bottom: -1px;
+			}
+			&:hover {
+				background-color: #eaeaea;
+			}
+		}
+		li.selected {
+			background-color: #eaeaea;
+		}
+	}
+}

+ 1 - 0
services/web/public/stylesheets/mainStyle.less

@@ -49,6 +49,7 @@
 @import "public/stylesheets/less/navbar.less";
 @import "public/stylesheets/less/footer.less";
 @import "public/stylesheets/less/list.less";
+@import "public/stylesheets/less/trackchanges.less";
 
 
 @import "public/stylesheets/less/fileuploader.less";

+ 46 - 0
services/web/test/UnitTests/coffee/TrackChanges/TrackChangesControllerTests.coffee

@@ -0,0 +1,46 @@
+chai = require('chai')
+chai.should()
+sinon = require("sinon")
+modulePath = "../../../../app/js/Features/TrackChanges/TrackChangesController"
+SandboxedModule = require('sandboxed-module')
+
+describe "TrackChangesController", ->
+	beforeEach ->
+		@TrackChangesController = SandboxedModule.require modulePath, requires:
+			"request" : @request = {}
+			"settings-sharelatex": @settings = {}
+			"logger-sharelatex": @logger = {log: sinon.stub(), error: sinon.stub()}
+
+	describe "proxyToTrackChangesApi", ->
+		beforeEach ->
+			@req = { url: "/mock/url" }
+			@res = "mock-res"
+			@next = sinon.stub()
+			@settings.apis =
+				trackchanges:
+					url: "http://trackchanges.example.com"
+			@proxy =
+				events: {}
+				pipe: sinon.stub()
+				on: (event, handler) -> @events[event] = handler
+			@request.get = sinon.stub().returns @proxy
+			@TrackChangesController.proxyToTrackChangesApi @req, @res, @next
+
+		describe "successfully", ->
+			it "should call the track changes api", ->
+				@request.get
+					.calledWith("#{@settings.apis.trackchanges.url}#{@req.url}")
+					.should.equal true
+
+			it "should pipe the response to the client", ->
+				@proxy.pipe
+					.calledWith(@res)
+					.should.equal true
+
+		describe "with an error", ->
+			beforeEach ->
+				@proxy.events["error"].call(@proxy, @error = new Error("oops"))
+
+			it "should pass the error up the call chain", ->
+				@next.calledWith(@error).should.equal true
+

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio