Explorar o código

Merge pull request #816 from sharelatex/pr-history-labels-part-2

History labels, part 2
Paulo Jorge Reis %!s(int64=8) %!d(string=hai) anos
pai
achega
31e374d7bf

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

@@ -65,7 +65,7 @@ block content
 					ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME) }",
 					layout="main",
 					ng-hide="state.loading",
-					resize-on="layout:chat:resize",
+					resize-on="layout:chat:resize,history:toggle",
 					minimum-restore-size-west="130"
 					custom-toggler-pane=hasFeature('custom-togglers') ? "'west'" : "false"
 					custom-toggler-msg-when-open=hasFeature('custom-togglers') ? "'" + translate("tooltip_hide_filetree") + "'" : "false"

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

@@ -70,17 +70,18 @@ script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 
 
 script(type="text/ng-template", id="historyLabelTpl")
-	.history-entry-label(
-		ng-class="{ 'history-entry-label-own' : $ctrl.isOwnedByCurrentUser }"
+	.history-label(
+		ng-class="{ 'history-label-own' : $ctrl.isOwnedByCurrentUser }"
 	)
-		span.history-entry-label-comment(
+		span.history-label-comment(
 			tooltip-append-to-body="true"
 			tooltip-template="'historyLabelTooltipTpl'"
 			tooltip-placement="left"
+			tooltip-enable="$ctrl.showTooltip"
 		)
 			i.fa.fa-tag
 			|  {{ $ctrl.labelText }}
-		button.history-entry-label-delete-btn(
+		button.history-label-delete-btn(
 			ng-if="$ctrl.isOwnedByCurrentUser"
 			stop-propagation="click"
 			ng-click="$ctrl.onLabelDelete()"

+ 46 - 2
services/web/app/views/project/editor/history/entriesListV2.pug

@@ -3,6 +3,7 @@ aside.change-list(
 	ng-controller="HistoryV2ListController"
 )
 	history-entries-list(
+		ng-if="!history.showOnlyLabels && !history.error"
 		entries="history.updates"
 		current-user="user"
 		users="projectUsers"
@@ -13,6 +14,16 @@ aside.change-list(
 		on-entry-select="handleEntrySelect(selectedEntry)"
 		on-label-delete="handleLabelDelete(label)"
 	)
+	history-labels-list(
+		ng-if="history.showOnlyLabels && !history.error"
+		labels="history.labels"
+		current-user="user"
+		users="projectUsers"
+		is-loading="history.loading"
+		selected-label="history.selection.label"
+		on-label-select="handleLabelSelect(label)"
+		on-label-delete="handleLabelDelete(label)"
+	)
 
 aside.change-list(
 	ng-if="history.isV2 && history.viewMode === HistoryViewModes.COMPARE"
@@ -141,7 +152,7 @@ script(type="text/ng-template", id="historyEntryTpl")
 
 		.history-entry-details(ng-click="$ctrl.onSelect({ selectedEntry: $ctrl.entry })")
 			history-label(
-				ng-repeat="label in $ctrl.entry.labels"
+				ng-repeat="label in $ctrl.entry.labels | orderBy : '-created_at'"
 				label-text="label.comment"
 				label-owner-name="$ctrl.displayNameById(label.user_id)"
 				label-creation-date-time="label.created_at"
@@ -192,4 +203,37 @@ script(type="text/ng-template", id="historyEntryTpl")
 					li.history-entry-metadata-user(ng-if="::$ctrl.entry.meta.users.length == 0")
 						span.name(
 							ng-style="$ctrl.getUserCSSStyle();"
-						) #{translate("anonymous")}
+						) #{translate("anonymous")}
+
+script(type="text/ng-template", id="historyLabelsListTpl")
+	.history-labels-list
+		.history-entry-label(
+			ng-repeat="label in $ctrl.labels track by label.id"
+			ng-click="$ctrl.onLabelSelect({ label: label })"
+			ng-class="{ 'history-entry-label-selected': label.id === $ctrl.selectedLabel.id }" 
+		)
+			history-label(
+				show-tooltip="false"
+				label-text="label.comment"
+				is-owned-by-current-user="label.user_id === $ctrl.currentUser.id"
+				on-label-delete="$ctrl.onLabelDelete({ label: label })"
+			)
+			.history-entry-label-metadata
+				.history-entry-label-metadata-user(ng-init="user = $ctrl.getUserById(label.user_id)")
+					| Saved by 
+					span.name(
+						ng-if="user && user._id !== $ctrl.currentUser.id"
+						ng-style="$ctrl.getUserCSSStyle(user, label);"
+					) {{ ::$ctrl.displayName(user) }} 
+					span.name(
+						ng-if="user && user._id == $ctrl.currentUser.id"
+						ng-style="$ctrl.getUserCSSStyle(user, label);"
+					) You
+					span.name(
+						ng-if="user == null"
+						ng-style="$ctrl.getUserCSSStyle(user, label);"
+					) #{translate("anonymous")}
+				time.history-entry-label-metadata-time {{ ::label.created_at | formatDate }}
+		.loading(ng-show="$ctrl.isLoading")
+			i.fa.fa-spin.fa-refresh
+			|    #{translate("loading")}...

+ 14 - 2
services/web/app/views/project/editor/history/previewPanelV2.pug

@@ -45,7 +45,7 @@
 			text="history.diff.text",
 			highlights="history.diff.highlights",
 			read-only="true",
-			resize-on="layout:main:resize",
+			resize-on="layout:main:resize,history:toggle",
 			navigate-highlights="true"
 		)
 		.alert.alert-info(ng-if="history.diff.binary")
@@ -70,12 +70,24 @@
 			font-size="settings.fontSize",
 			text="history.selectedFile.text",
 			read-only="true",
-			resize-on="layout:main:resize",
+			resize-on="layout:main:resize,history:toggle",
 		)
 		.alert.alert-info(ng-if="history.selectedFile.binary")
 			| We're still working on showing image and binary changes, sorry. Stay tuned!
 	.loading-panel(ng-show="history.selectedFile.loading")
 		i.fa.fa-spin.fa-refresh
 		|   #{translate("loading")}...
+	.error-panel(ng-show="history.error")
+		.alert.alert-danger 
+			p
+				| #{translate("generic_history_error")} 
+				a(
+					ng-href="mailto:#{settings.adminEmail}?Subject=Error%20loading%20history%20for%project%20{{ project_id }}"
+				) #{settings.adminEmail}
+			p.clearfix
+				a.alert-link-as-btn.pull-right(
+					href
+					ng-click="toggleHistory()"
+			) #{translate("back_to_editor")}
 	.error-panel(ng-show="history.selectedFile.error")
 		.alert.alert-danger #{translate("generic_something_went_wrong")}

+ 34 - 14
services/web/app/views/project/editor/history/toolbarV2.pug

@@ -2,23 +2,43 @@
 	ng-controller="HistoryV2ToolbarController"
 	ng-if="ui.view == 'history' && history.isV2 && history.viewMode === HistoryViewModes.POINT_IN_TIME"
 )
-	span(ng-show="history.loadingFileTree")
+	span.history-toolbar-selected-version(ng-show="history.loadingFileTree")
 		i.fa.fa-spin.fa-refresh
 		|    #{translate("loading")}...
-	span(ng-show="!history.loadingFileTree") #{translate("browsing_project_as_of")} 
+	span.history-toolbar-selected-version(
+		ng-show="!history.loadingFileTree && !history.showOnlyLabels && !history.error"
+	) #{translate("browsing_project_as_of")} 
 		time.history-toolbar-time {{ history.selection.updates[0].meta.end_ts | formatDate:'Do MMM YYYY, h:mm a' }}
-	button.history-toolbar-btn(
-		ng-click="showAddLabelDialog();"
-		ng-disabled="history.loadingFileTree"
-	) 
-		i.fa.fa-tag
-		|  #{translate("history_label_this_version")}
-	button.history-toolbar-btn(
-		ng-click="toggleHistoryViewMode();"
-		ng-disabled="history.loadingFileTree"
-	) 
-		i.fa.fa-exchange
-		|  #{translate("compare_to_another_version")}
+	span.history-toolbar-selected-version(
+		ng-show="!history.loadingFileTree && history.showOnlyLabels && history.selection.label && !history.error"
+	) #{translate("browsing_project_labelled")} 
+		span.history-toolbar-selected-label "{{ history.selection.label.comment }}"
+	div.history-toolbar-actions(
+		ng-if="!history.error"
+	)
+		button.history-toolbar-btn(
+			ng-click="showAddLabelDialog();"
+			ng-if="!history.showOnlyLabels"
+			ng-disabled="history.loadingFileTree"
+		) 
+			i.fa.fa-tag
+			|  #{translate("history_label_this_version")}
+		button.history-toolbar-btn(
+			ng-click="toggleHistoryViewMode();"
+			ng-disabled="history.loadingFileTree"
+		) 
+			i.fa.fa-exchange
+			|  #{translate("compare_to_another_version")}
+
+	.history-toolbar-entries-list(
+		ng-if="!history.error"
+	)
+		toggle-switch(
+			ng-model="history.showOnlyLabels"
+			label-true=translate("history_view_labels")
+			label-false=translate("history_view_all")
+			description=translate("history_view_a11y_description")
+		)
 
 script(type="text/ng-template", id="historyV2AddLabelModalTemplate")
 	form(

+ 2 - 1
services/web/public/coffee/ide/directives/layout.coffee

@@ -112,7 +112,8 @@ define [
 					element.layout().resizeAll()
 
 					if attrs.resizeOn?
-						scope.$on attrs.resizeOn, () -> onExternalResize()
+						for event in attrs.resizeOn.split ","
+							scope.$on event, () -> onExternalResize()
 
 					if hasCustomToggler
 						state = element.layout().readState()

+ 131 - 19
services/web/public/coffee/ide/history/HistoryV2Manager.coffee

@@ -12,6 +12,7 @@ define [
 	"ide/history/directives/infiniteScroll"
 	"ide/history/components/historyEntriesList"
 	"ide/history/components/historyEntry"
+	"ide/history/components/historyLabelsList"
 	"ide/history/components/historyLabel"
 	"ide/history/components/historyFileTree"
 	"ide/history/components/historyFileEntity"
@@ -26,6 +27,9 @@ define [
 					@hide()
 				else
 					@show()
+				@ide.$timeout () =>
+					@$scope.$broadcast "history:toggle"
+				, 0
 
 			@$scope.toggleHistoryViewMode = () =>
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
@@ -34,6 +38,9 @@ define [
 				else
 					@reset()
 					@$scope.history.viewMode = HistoryViewModes.COMPARE
+				@ide.$timeout () =>
+					@$scope.$broadcast "history:toggle"
+				, 0
 
 			@$scope.$watch "history.selection.updates", (updates) =>
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
@@ -48,6 +55,18 @@ define [
 				else 
 					@reloadDiff()
 
+			@$scope.$watch "history.showOnlyLabels", (showOnlyLabels, prevVal) =>
+				if showOnlyLabels? and showOnlyLabels != prevVal 
+					if showOnlyLabels
+						@selectedLabelFromUpdatesSelection()
+					else
+						@$scope.history.selection.label = null
+						if @$scope.history.selection.updates.length == 0
+							@autoSelectLastUpdate()
+
+			@$scope.$watch "history.updates.length", () =>
+				@recalculateSelectedUpdates()
+
 		show: () ->
 			@$scope.ui.view = "history"
 			@reset()
@@ -64,6 +83,7 @@ define [
 				nextBeforeTimestamp: null
 				atEnd: false
 				selection: {
+					label: null
 					updates: []
 					docs: {}
 					pathname: null
@@ -72,6 +92,8 @@ define [
 						toV: null
 					}
 				}
+				error: null
+				showOnlyLabels: false
 				labels: null
 				files: []
 				diff: null # When history.viewMode == HistoryViewModes.COMPARE
@@ -86,10 +108,9 @@ define [
 				_csrf: window.csrfToken
 			})
 
-		loadFileTreeForUpdate: (update) ->
-			{fromV, toV} = update
+		loadFileTreeForVersion: (version) ->
 			url = "/project/#{@$scope.project_id}/filetree/diff"
-			query = [ "from=#{toV}", "to=#{toV}" ]
+			query = [ "from=#{version}", "to=#{version}" ]
 			url += "?" + query.join("&")
 			@$scope.history.loadingFileTree = true
 			@$scope.history.selectedFile = null
@@ -118,6 +139,10 @@ define [
 			return if @$scope.history.updates.length == 0
 			@selectUpdate @$scope.history.updates[0]
 
+		autoSelectLastLabel: () ->
+			return if @$scope.history.labels.length == 0
+			@selectLabel @$scope.history.labels[0]
+			
 		selectUpdate: (update) ->
 			selectedUpdateIndex = @$scope.history.updates.indexOf update
 			if selectedUpdateIndex == -1
@@ -127,29 +152,107 @@ define [
 				update.selectedFrom = false
 			@$scope.history.updates[selectedUpdateIndex].selectedTo = true
 			@$scope.history.updates[selectedUpdateIndex].selectedFrom = true
-			@loadFileTreeForUpdate @$scope.history.updates[selectedUpdateIndex]
+			@recalculateSelectedUpdates()
+			@loadFileTreeForVersion @$scope.history.updates[selectedUpdateIndex].toV
+
+		selectedLabelFromUpdatesSelection: () ->
+			# Get the number of labels associated with the currently selected update
+			nSelectedLabels = @$scope.history.selection.updates?[0]?.labels?.length
+			# If the currently selected update has no labels, select the last one (version-wise)
+			if nSelectedLabels == 0
+				@autoSelectLastLabel()
+			# If the update has one label, select it 
+			else if nSelectedLabels == 1
+				@selectLabel @$scope.history.selection.updates[0].labels[0]
+			# If there are multiple labels for the update, select the latest
+			else if nSelectedLabels > 1
+				sortedLabels = @ide.$filter("orderBy")(@$scope.history.selection.updates[0].labels, '-created_at')
+				lastLabelFromUpdate = sortedLabels[0]
+				@selectLabel lastLabelFromUpdate
+				
+		selectLabel: (labelToSelect) ->
+			updateToSelect = null
+
+			if @_isLabelSelected labelToSelect
+				# Label already selected
+				return
+
+			for update in @$scope.history.updates
+				if update.toV == labelToSelect.version
+					updateToSelect = update
+					break
+
+			@$scope.history.selection.label = labelToSelect
+			if updateToSelect?
+				@selectUpdate updateToSelect
+			else
+				@$scope.history.selection.updates = []
+				@loadFileTreeForVersion labelToSelect.version
+
+		recalculateSelectedUpdates: () ->
+			beforeSelection = true
+			afterSelection = false
+			@$scope.history.selection.updates = []
+			for update in @$scope.history.updates
+				if update.selectedTo
+					inSelection = true
+					beforeSelection = false
+
+				update.beforeSelection = beforeSelection
+				update.inSelection = inSelection
+				update.afterSelection = afterSelection
+
+				if inSelection
+					@$scope.history.selection.updates.push update
+
+				if update.selectedFrom
+					inSelection = false
+					afterSelection = true
 
 		BATCH_SIZE: 10
 		fetchNextBatchOfUpdates: () ->
 			updatesURL = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
 			if @$scope.history.nextBeforeTimestamp?
 				updatesURL += "&before=#{@$scope.history.nextBeforeTimestamp}"
-			
+			labelsURL = "/project/#{@ide.project_id}/labels"
+
 			@$scope.history.loading = true
 			@$scope.history.loadingFileTree = true
 			
-			@ide.$http.get updatesURL
+			requests = 
+				updates: @ide.$http.get updatesURL
+			
+			if !@$scope.history.labels?
+				requests.labels = @ide.$http.get labelsURL
+
+			@ide.$q.all requests
 				.then (response) =>
-					updatesData = response.data
+					updatesData = response.updates.data
+					if response.labels?
+						@$scope.history.labels = @_sortLabelsByVersionAndDate response.labels.data
 					@_loadUpdates(updatesData.updates)
 					@$scope.history.nextBeforeTimestamp = updatesData.nextBeforeTimestamp
 					if !updatesData.nextBeforeTimestamp?
 						@$scope.history.atEnd = true
 					@$scope.history.loading = false
+				.catch (error) =>
+					{ status, statusText } = error
+					@$scope.history.error = { status, statusText }
+					@$scope.history.loading = false
+					@$scope.history.loadingFileTree = false
+
+		_sortLabelsByVersionAndDate: (labels) ->
+			@ide.$filter("orderBy")(labels, [ '-version', '-created_at' ])
 
 		loadFileAtPointInTime: () ->
 			pathname = @$scope.history.selection.pathname
-			toV =  @$scope.history.selection.updates[0].toV
+			if @$scope.history.selection.updates?[0]?
+				toV = @$scope.history.selection.updates[0].toV
+			else if @$scope.history.selection.label?
+				toV = @$scope.history.selection.label.version
+
+			if !toV?
+				return
 			url = "/project/#{@$scope.project_id}/diff"
 			query = ["pathname=#{encodeURIComponent(pathname)}", "from=#{toV}", "to=#{toV}"]
 			url += "?" + query.join("&")
@@ -208,8 +311,8 @@ define [
 		labelCurrentVersion: (labelComment) => 
 			@_labelVersion labelComment, @$scope.history.selection.updates[0].toV
 
-		deleteLabel: (labelId) =>
-			url = "/project/#{@$scope.project_id}/labels/#{labelId}"
+		deleteLabel: (label) =>
+			url = "/project/#{@$scope.project_id}/labels/#{label.id}"
 
 			@ide.$http({
 				url,
@@ -217,14 +320,19 @@ define [
 				headers:
 					"X-CSRF-Token": window.csrfToken
 			}).then (response) =>
-				@_deleteLabelFromLocalCollection @$scope.history.updates, labelId
-				@_deleteLabelFromLocalCollection @$scope.history.selection, labelId
+				@_deleteLabelLocally label
 
+		_isLabelSelected: (label) ->
+			label.id == @$scope.history.selection.label?.id
 
-		_deleteLabelFromLocalCollection: (collection, labelId) ->
-			for update in collection
-				update.labels = _.filter update.labels, (label) -> 
-					label.id != labelId
+		_deleteLabelLocally: (labelToDelete) ->
+			for update, i in @$scope.history.updates
+				if update.toV == labelToDelete.version
+					update.labels = _.filter update.labels, (label) -> 
+						label.id != labelToDelete.id
+					break 
+			@$scope.history.labels = _.filter @$scope.history.labels, (label) -> 
+				label.id != labelToDelete.id
 
 		_parseDiff: (diff) ->
 			if diff.binary
@@ -303,7 +411,10 @@ define [
 				if @$scope.history.viewMode == HistoryViewModes.COMPARE
 					@autoSelectRecentUpdates()
 				else 
-					@autoSelectLastUpdate()
+					if @$scope.history.showOnlyLabels
+						@autoSelectLastLabel()
+					else
+						@autoSelectLastUpdate()
 
 		_labelVersion: (comment, version) ->
 			url = "/project/#{@$scope.project_id}/labels"
@@ -316,10 +427,11 @@ define [
 				.then (response) =>
 					@_addLabelToLocalUpdate response.data
 
-		_addLabelToLocalUpdate: (label) ->
+		_addLabelToLocalUpdate: (label) =>
 			localUpdate = _.find @$scope.history.updates, (update) -> update.toV == label.version
 			if localUpdate?
-				localUpdate.labels.push label
+				localUpdate.labels = @_sortLabelsByVersionAndDate localUpdate.labels.concat label
+			@$scope.history.labels = @_sortLabelsByVersionAndDate @$scope.history.labels.concat label
 
 		_perDocSummaryOfUpdates: (updates) ->
 			# Track current_pathname -> original_pathname

+ 16 - 0
services/web/public/coffee/ide/history/components/historyEntriesList.coffee

@@ -3,6 +3,22 @@ define [
 ], (App) ->
 	historyEntriesListController = ($scope, $element, $attrs) ->
 		ctrl = @ 
+		ctrl.$entryListViewportEl = null
+		_isEntryElVisible = ($entryEl) ->
+			entryElTop = $entryEl.offset().top
+			entryElBottom = entryElTop + $entryEl.outerHeight()
+			entryListViewportElTop = ctrl.$entryListViewportEl.offset().top
+			entryListViewportElBottom = entryListViewportElTop + ctrl.$entryListViewportEl.height()
+			return entryElTop >= entryListViewportElTop and entryElBottom <= entryListViewportElBottom;
+		_getScrollTopPosForEntry = ($entryEl) ->
+			halfViewportElHeight = ctrl.$entryListViewportEl.height() / 2
+			return $entryEl.offset().top - halfViewportElHeight
+		ctrl.onEntryLinked = (entry, $entryEl) ->
+			if entry.selectedTo and entry.selectedFrom and !_isEntryElVisible $entryEl
+				$scope.$applyAsync () ->
+					ctrl.$entryListViewportEl.scrollTop _getScrollTopPosForEntry $entryEl
+		ctrl.$onInit = () ->
+			ctrl.$entryListViewportEl = $element.find "> .history-entries"
 		return
 
 	App.component "historyEntriesList", {

+ 8 - 2
services/web/public/coffee/ide/history/components/historyEntry.coffee

@@ -1,7 +1,8 @@
 define [
 	"base"
+	"ide/colors/ColorManager"
 	"ide/history/util/displayNameForUser"
-], (App, displayNameForUser) ->
+], (App, ColorManager, displayNameForUser) ->
 	historyEntryController = ($scope, $element, $attrs, _) ->
 		ctrl = @
 		# This method (and maybe the one below) will be removed soon. User details data will be 
@@ -19,11 +20,14 @@ define [
 			else if projectOp.add? then "#{ projectOp.add.pathname}"
 			else if projectOp.remove? then "#{ projectOp.remove.pathname}"
 		ctrl.getUserCSSStyle = (user) ->
-			hue = user?.hue or 100
+			curUserId = user?._id or user?.id
+			hue = ColorManager.getHueForUserId(curUserId) or 100
 			if ctrl.entry.inSelection 
 				color : "#FFF" 
 			else 
 				color: "hsl(#{ hue }, 70%, 50%)"
+		ctrl.$onInit = () ->
+			ctrl.historyEntriesList.onEntryLinked ctrl.entry, $element.find "> .history-entry"
 		return
 
 	App.component "historyEntry", {
@@ -33,6 +37,8 @@ define [
 			users: "<"
 			onSelect: "&"
 			onLabelDelete: "&"
+		require:
+			historyEntriesList: '^historyEntriesList'
 		controller: historyEntryController
 		templateUrl: "historyEntryTpl"
 	}

+ 5 - 2
services/web/public/coffee/ide/history/components/historyLabel.coffee

@@ -3,15 +3,18 @@ define [
 ], (App) ->
 	historyLabelController = ($scope, $element, $attrs, $filter, _) ->
 		ctrl = @
+		ctrl.$onInit = () ->
+			ctrl.showTooltip ?= true
 		return
 
 	App.component "historyLabel", {
 		bindings:
 			labelText: "<"
-			labelOwnerName: "<"
-			labelCreationDateTime: "<"
+			labelOwnerName: "<?"
+			labelCreationDateTime: "<?"
 			isOwnedByCurrentUser: "<"
 			onLabelDelete: "&"
+			showTooltip: "<?"
 		controller: historyLabelController
 		templateUrl: "historyLabelTpl"
 	}

+ 36 - 0
services/web/public/coffee/ide/history/components/historyLabelsList.coffee

@@ -0,0 +1,36 @@
+define [
+	"base"
+	"ide/colors/ColorManager"
+	"ide/history/util/displayNameForUser"
+], (App, ColorManager, displayNameForUser) ->
+	historyLabelsListController = ($scope, $element, $attrs) ->
+		ctrl = @ 
+		# This method (and maybe the one below) will be removed soon. User details data will be 
+		# injected into the history API responses, so we won't need to fetch user data from other
+		# local data structures.
+		ctrl.getUserById = (id) ->
+			_.find ctrl.users, (user) ->
+				curUserId = user?._id or user?.id
+				curUserId == id
+		ctrl.displayName = displayNameForUser
+		ctrl.getUserCSSStyle = (user, label) ->
+			curUserId = user?._id or user?.id
+			hue = ColorManager.getHueForUserId(curUserId) or 100
+			if label.id == ctrl.selectedLabel?.id
+				color : "#FFF" 
+			else 
+				color: "hsl(#{ hue }, 70%, 50%)"
+		return
+
+	App.component "historyLabelsList", {
+		bindings:
+			labels: "<"
+			users: "<"
+			currentUser: "<"
+			isLoading: "<"
+			selectedLabel: "<"
+			onLabelSelect: "&"
+			onLabelDelete: "&"
+		controller: historyLabelsListController
+		templateUrl: "historyLabelsListTpl"
+	}

+ 1 - 1
services/web/public/coffee/ide/history/controllers/HistoryV2DeleteLabelModalController.coffee

@@ -9,7 +9,7 @@ define [
 
 		$scope.deleteLabel = () ->
 			$scope.state.inflight = true
-			ide.historyManager.deleteLabel labelDetails.id
+			ide.historyManager.deleteLabel labelDetails
 				.then (response) ->
 					$scope.state.inflight = false
 					$modalInstance.close()

+ 3 - 61
services/web/public/coffee/ide/history/controllers/HistoryV2ListController.coffee

@@ -19,8 +19,10 @@ define [
 
 		$scope.handleEntrySelect = (entry) ->
 			ide.historyManager.selectUpdate(entry)
-			$scope.recalculateSelectedUpdates()
 
+		$scope.handleLabelSelect = (label) ->
+			ide.historyManager.selectLabel(label)
+			
 		$scope.handleLabelDelete = (labelDetails) ->
 			$modal.open(
 				templateUrl: "historyV2DeleteLabelModalTemplate"
@@ -28,64 +30,4 @@ define [
 				resolve:
 					labelDetails: () -> labelDetails
 			)
-
-		$scope.recalculateSelectedUpdates = () ->
-			beforeSelection = true
-			afterSelection = false
-			$scope.history.selection.updates = []
-			for update in $scope.history.updates
-				if update.selectedTo
-					inSelection = true
-					beforeSelection = false
-
-				update.beforeSelection = beforeSelection
-				update.inSelection = inSelection
-				update.afterSelection = afterSelection
-
-				if inSelection
-					$scope.history.selection.updates.push update
-
-				if update.selectedFrom
-					inSelection = false
-					afterSelection = true
-
-		$scope.recalculateHoveredUpdates = () ->
-			hoverSelectedFrom = false
-			hoverSelectedTo = false
-			for update in $scope.history.updates
-				# Figure out whether the to or from selector is hovered over
-				if update.hoverSelectedFrom
-					hoverSelectedFrom = true
-				if update.hoverSelectedTo
-					hoverSelectedTo = true
-
-			if hoverSelectedFrom
-				# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
-				inHoverSelection = false
-				for update in $scope.history.updates
-					if update.selectedTo
-						update.hoverSelectedTo = true
-						inHoverSelection = true
-					update.inHoverSelection = inHoverSelection
-					if update.hoverSelectedFrom
-						inHoverSelection = false
-			if hoverSelectedTo
-				# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
-				inHoverSelection = false
-				for update in $scope.history.updates
-					if update.hoverSelectedTo
-						inHoverSelection = true
-					update.inHoverSelection = inHoverSelection
-					if update.selectedFrom
-						update.hoverSelectedFrom = true
-						inHoverSelection = false
-
-		$scope.resetHoverState = () ->
-			for update in $scope.history.updates
-				delete update.hoverSelectedFrom
-				delete update.hoverSelectedTo
-				delete update.inHoverSelection
-
-		$scope.$watch "history.updates.length", () ->
-			$scope.recalculateSelectedUpdates()
 	]

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

@@ -3,11 +3,13 @@ define [
 ], (App) ->
 	# We create and provide this as service so that we can access the global ide
 	# from within other parts of the angular app.
-	App.factory "ide", ["$http", "queuedHttp", "$modal", "$q", ($http, queuedHttp, $modal, $q) ->
+	App.factory "ide", ["$http", "queuedHttp", "$modal", "$q", "$filter", "$timeout", ($http, queuedHttp, $modal, $q, $filter, $timeout) ->
 		ide = {}
 		ide.$http = $http
 		ide.queuedHttp = queuedHttp
 		ide.$q = $q
+		ide.$filter = $filter
+		ide.$timeout = $timeout
 
 		@recentEvents = []
 		ide.pushEvent = (type, meta = {}) =>

+ 52 - 348
services/web/public/stylesheets/app/editor/history-v2.less

@@ -15,16 +15,30 @@
 .history-toolbar when (@is-overleaf = false) {
 	border-bottom: @toolbar-border-bottom;
 }
-	.history-toolbar-time {
-		font-weight: bold;
+	.history-toolbar-selected-version {
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
 	}
-	.history-toolbar-btn {
-		.btn;
-		.btn-info;
-		.btn-xs;
-		padding-left: @padding-small-horizontal;
-		padding-right: @padding-small-horizontal;
-		margin-left: (@line-height-computed / 2);
+		.history-toolbar-time,
+		.history-toolbar-selected-label {
+			font-weight: bold;
+		}
+	.history-toolbar-actions {
+		flex-grow: 1;
+	}
+		.history-toolbar-btn {
+			.btn;
+			.btn-info;
+			.btn-xs;
+			padding-left: @padding-small-horizontal;
+			padding-right: @padding-small-horizontal;
+			margin-left: (@line-height-computed / 2);
+		}
+	.history-toolbar-entries-list {
+		flex: 0 0 @changesListWidth;
+		padding: 0 10px;
+		border-left: 1px solid @editor-border-color;
 	}
 
 .history-entries {
@@ -48,51 +62,54 @@
 	padding: 5px 10px;
 	cursor: pointer;
 
-	.history-entry-selected & {
+	.history-entry-selected &,
+	.history-entry-label-selected & {
 		background-color: @history-entry-selected-bg;
 		color: #FFF;
 	}
 }
-
-	.history-entry-label {
+	.history-label {
 		display: inline-block;
 		color: @history-entry-label-color;
 		font-size: @font-size-small;
 		margin-bottom: 3px;
 		margin-right: 10px;
 		white-space: nowrap;
-		.history-entry-selected & {
+		.history-entry-selected &,
+		.history-entry-label-selected & {
 			color: @history-entry-selected-label-color;
 		}
 	}
-		.history-entry-label-comment,
-		.history-entry-label-delete-btn {
+		.history-label-comment,
+		.history-label-delete-btn {
 			padding: 0 @padding-xs-horizontal 1px @padding-xs-horizontal;
 			border: 0;
 			background-color: @history-entry-label-bg-color;
-			.history-entry-selected & {
+			.history-entry-selected &,
+			.history-entry-label-selected & {
 				background-color: @history-entry-selected-label-bg-color;
 			}
 		}
-		.history-entry-label-comment {
+		.history-label-comment {
 			display: block;
 			float: left;
 			border-radius: 9999px;
 			max-width: 190px;
 			overflow: hidden;
 			text-overflow: ellipsis;
-			.history-entry-label-own & {
+			.history-label-own & {
 				padding-right: (@padding-xs-horizontal / 2);
 				border-radius: 9999px 0 0 9999px;
 			}
 		}
-		.history-entry-label-delete-btn {
+		.history-label-delete-btn {
 			padding-left: (@padding-xs-horizontal / 2);
 			padding-right: @padding-xs-horizontal;
 			border-radius: 0 9999px 9999px 0;
 			&:hover {
 				background-color: darken(@history-entry-label-bg-color, 8%);
-				.history-entry-selected & {
+				.history-entry-selected &,
+				.history-entry-label-selected & {
 					background-color: darken(@history-entry-selected-label-bg-color, 8%);
 				}
 			}
@@ -130,7 +147,8 @@
 				color: @history-highlight-color;
 				font-weight: bold;
 				word-break: break-all;
-				.history-entry-selected & {
+				.history-entry-selected &,
+				.history-entry-label-selected & {
 					color: #FFF;
 				}
 			}
@@ -155,6 +173,19 @@
 				}
 			}
 
+.history-labels-list {
+	.history-entries;
+	overflow-y: auto;
+}
+	.history-entry-label {
+		.history-entry-details;
+		padding: 7px 10px;
+		&.history-entry-label-selected {
+			background-color: @history-entry-selected-bg;
+			color: #FFF;
+		}
+	}
+
 .history-file-tree-inner {
 	.full-size;
 	overflow-y: auto;
@@ -231,330 +262,3 @@
 				color: @brand-primary;
 			}
 		}
-// @changesListWidth:   250px;
-// @changesListPadding: @line-height-computed / 2;
-
-// @selector-padding-vertical: 10px;
-// @selector-padding-horizontal: @line-height-computed / 2;
-// @day-header-height: 24px;
-
-// @range-bar-color: @link-color;
-// @range-bar-selected-offset: 14px;
-
-// #history {
-// 	.upgrade-prompt {
-// 		position: absolute;
-// 		top: 0;
-// 		bottom: 0;
-// 		left: 0;
-// 		right: 0;
-// 		z-index: 100;
-// 		background-color: rgba(128,128,128,0.4);
-// 		.message {
-// 			margin: auto;
-// 			margin-top: 100px;
-// 			padding: (@line-height-computed / 2) @line-height-computed;
-// 			width: 400px;
-// 			background-color: white;
-// 			border-radius: 8px;
-// 		}
-// 		.message-wider {
-// 			width: 650px;
-// 			margin-top: 60px;
-// 			padding: 0;
-// 		}
-
-// 			.message-header {
-// 				.modal-header;
-// 			}
-
-// 			.message-body {
-// 				.modal-body;
-// 			}
-// 	}
-
-// 	.diff-panel {
-// 		.full-size;
-// 		margin-right: @changesListWidth;
-// 	}
-
-// 	.diff {
-// 		.full-size;
-// 		.toolbar {
-// 			padding: 3px;
-// 			.name {
-// 				float: left;
-// 				padding: 3px @line-height-computed / 4;
-// 				display: inline-block;
-// 			}
-// 		}
-// 		.diff-editor {
-// 			.full-size;
-// 			top: 40px;
-// 		}
-// 		.hide-ace-cursor {
-// 			.ace_active-line, .ace_cursor-layer, .ace_gutter-active-line {
-// 				display: none;
-// 			}
-// 		}
-// 		.diff-deleted {
-// 			padding: @line-height-computed;
-// 		}
-// 		.deleted-warning {
-// 			background-color: @brand-danger;
-// 			color: white;
-// 			padding: @line-height-computed / 2;
-// 			margin-right: @line-height-computed / 4;
-// 		}
-// 		&-binary {
-// 			.alert {
-// 				margin: @line-height-computed / 2;
-// 			}
-// 		}
-// 	}
-
-// 	aside.change-list {
-// 		border-left: 1px solid @editor-border-color;
-// 		height: 100%;
-// 		width: @changesListWidth;
-// 		position: absolute;
-// 		right: 0;
-
-// 		.loading {
-// 			text-align: center;
-// 			font-family: @font-family-serif;
-// 		}
-
-// 		ul {
-// 			li.change {
-// 				position: relative;
-// 				user-select: none;
-// 				-ms-user-select: none;
-// 				-moz-user-select: none;
-// 				-webkit-user-select: none;
-
-// 				.day {
-// 					background-color: #fafafa;
-// 					border-bottom: 1px solid @editor-border-color;
-// 					padding: 4px;
-// 					font-weight: bold;
-// 					text-align: center;
-// 					height: @day-header-height;
-// 					font-size: 14px;
-// 					line-height: 1;
-// 				}
-// 				.selectors {
-// 					input {
-// 						margin: 0;
-// 					}
-// 					position: absolute;
-// 					left: @selector-padding-horizontal;
-// 					top: 0;
-// 					bottom: 0;
-// 					width: 24px;
-// 					.selector-from {
-// 						position: absolute;
-// 						bottom: @selector-padding-vertical;
-// 						left: 0;
-// 						opacity: 0.8;
-// 					}
-// 					.selector-to {
-// 						position: absolute;
-// 						top: @selector-padding-vertical;
-// 						left: 0;
-// 						opacity: 0.8;
-// 					}
-// 					.range {
-// 						position: absolute;
-// 						left: 5px;
-// 						width: 4px;
-// 						top: 0;
-// 						bottom: 0;
-// 					}
-// 				}
-// 				.description {
-// 					padding: (@line-height-computed / 4);
-// 					padding-left: 38px;
-// 					min-height: 38px;
-// 					border-bottom: 1px solid @editor-border-color;
-// 					cursor: pointer;
-// 					&:hover {
-// 						background-color: @gray-lightest;
-// 					}
-// 				}
-// 				.users {
-// 					.user {
-// 						font-size: 0.8rem;
-// 						color: @gray;
-// 						text-transform: capitalize;
-// 						position: relative;
-// 						padding-left: 16px;
-// 						.color-square {
-// 							height: 12px;
-// 							width: 12px;
-// 							border-radius: 3px;
-// 							position: absolute;
-// 							left: 0;
-// 							bottom: 3px;
-// 						}
-// 						.name {
-// 							width: 94%;
-// 							white-space: nowrap;
-// 							overflow: hidden;
-// 							text-overflow: ellipsis;
-// 						}
-// 					}
-// 				}
-// 				.time {
-// 					float: right;
-// 					color: @gray;
-// 					display: inline-block;
-// 					padding-right: (@line-height-computed / 2);
-// 					font-size: 0.8rem;
-// 					line-height: @line-height-computed;
-// 				}
-// 				.doc {
-// 					font-size: 0.9rem;
-// 					font-weight: bold;
-// 				}
-// 				.action {
-// 					color: @gray;
-// 					text-transform: uppercase;
-// 					font-size: 0.7em;
-// 					margin-bottom: -2px;
-// 					margin-top: 2px;
-// 					&-edited {
-// 						margin-top: 0;
-// 					}
-// 				}
-// 			}
-// 			li.loading-changes, li.empty-message {
-// 				padding: 6px;
-// 				cursor: default;
-// 				&:hover {
-// 					background-color: inherit;
-// 				}
-// 			}
-// 			li.selected {
-// 				border-left: 4px solid @range-bar-color;
-// 				.day {
-// 					padding-left: 0;
-// 				}
-// 				.description {
-// 					padding-left: 34px;
-// 				}
-// 				.selectors {
-// 					left: @selector-padding-horizontal - 4px;
-// 					.range {
-// 						background-color: @range-bar-color;
-// 					}
-// 				}
-// 			}
-// 			li.selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @range-bar-selected-offset;
-// 					}
-// 					.selector-to {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.selected-from {
-// 				.selectors {
-// 					.range {
-// 						bottom: @range-bar-selected-offset;
-// 					}
-// 					.selector-from {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.first-in-day {
-// 				.selectors {
-// 					.selector-to {
-// 						top: @day-header-height + @selector-padding-vertical;
-// 					}
-// 				}
-// 			}
-// 			li.first-in-day.selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @day-header-height + @range-bar-selected-offset;
-// 					}
-// 				}
-// 			}
-// 		}
-// 		ul.hover-state {
-// 			li {
-// 				.selectors {
-// 					.range {
-// 						background-color: transparent;
-// 						top: 0;
-// 						bottom: 0;
-// 					}
-// 				}
-// 			}
-// 			li.hover-selected {
-// 				.selectors {
-// 					.range {
-// 						top: 0;
-// 						background-color: @gray-light;
-// 					}
-// 				}
-// 			}
-// 			li.hover-selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @range-bar-selected-offset;
-// 					}
-// 					.selector-to {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.hover-selected-from {
-// 				.selectors {
-// 					.range {
-// 						bottom: @range-bar-selected-offset;
-// 					}
-// 					.selector-from {
-// 						opacity: 1;
-// 					}
-// 				}
-// 			}
-// 			li.first-in-day.hover-selected-to {
-// 				.selectors {
-// 					.range {
-// 						top: @day-header-height + @range-bar-selected-offset;
-// 					}
-// 				}
-// 			}
-// 		}
-// 	}
-// }
-
-// .diff-deleted {
-// 	padding-top: 15px;
-// }
-
-// .editor-dark {
-// 	#history {
-// 		aside.change-list {
-// 			border-color: @editor-dark-toolbar-border-color;
-			
-// 			ul li.change {
-// 				.day {
-// 					background-color: darken(@editor-dark-background-color, 10%);
-// 					border-bottom: 1px solid @editor-dark-toolbar-border-color;
-// 				}
-// 				.description {
-// 					border-bottom: 1px solid @editor-dark-toolbar-border-color;
-// 					&:hover {
-// 						background-color: black;
-// 					}
-// 				}
-// 			}
-// 		}
-// 	}
-// }

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

@@ -194,56 +194,57 @@
 }
 
 .toggle-switch {
-  position: relative;
+	position: relative;
 	height: 100%;
 	width: 100%;
-  background-color: @toggle-switch-bg;
-  border-radius: @btn-border-radius-base;
+	background-color: @toggle-switch-bg;
+	border-radius: @btn-border-radius-base;
 }
 
 .toggle-switch-label {
 	position: relative;
 	display: block;
 	font-weight: normal;
-  z-index: 2;
-  float: left;
-  width: 50%;
-  height: 100%;
-  line-height: 24px;
+	z-index: 2;
+	float: left;
+	width: 50%;
+	height: 100%;
+	line-height: 24px;
 	text-align: center;
 	margin-bottom: 0;
-  cursor: pointer;
-  user-select: none;
-  transition: color 0.12s ease-out;
+	cursor: pointer;
+	user-select: none;
+	color: @text-color;
+	transition: color 0.12s ease-out;
 }
 
 .toggle-switch-input {
-  position: absolute;
-  opacity: 0;
+	position: absolute;
+	opacity: 0;
 }
 
 .toggle-switch-input:checked + .toggle-switch-label {
-  color: #fff;
-  font-weight: bold;
+	color: #fff;
+	font-weight: bold;
 }
 
 .toggle-switch-selection {
-  display: block;
-  position: absolute;
-  z-index: 1;
-  top: 2px;
-  left: 2px;
-  right: 2px;
-  width: calc(~"50% - 2px");
-  height: calc(~"100% - 4px");
-  background: @toggle-switch-highlight-color;
-  border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
-  transition: transform 0.12s ease-out, border-radius 0.12s ease-out;
+	display: block;
+	position: absolute;
+	z-index: 1;
+	top: 2px;
+	left: 2px;
+	right: 2px;
+	width: calc(~"50% - 2px");
+	height: calc(~"100% - 4px");
+	background: @toggle-switch-highlight-color;
+	border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
+	transition: transform 0.12s ease-out, border-radius 0.12s ease-out;
 }
 
 .toggle-switch-input:checked:nth-child(4) ~ .toggle-switch-selection {
-  transform: translate(100%);
-  border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
+	transform: translate(100%);
+	border-radius: 0 @btn-border-radius-base @btn-border-radius-base 0;
 }
 
 /**************************************

+ 4 - 1
services/web/test/unit_frontend/coffee/ide/history/HistoryV2ManagerTests.coffee

@@ -15,14 +15,17 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) ->
 				nextBeforeTimestamp: null
 				atEnd: false
 				selection: {
+					label: null
 					updates: []
-					pathname: null
 					docs: {}
+					pathname: null
 					range: {
 						fromV: null
 						toV: null
 					}
 				}
+				error: null
+				showOnlyLabels: false
 				labels: null
 				diff: null
 				files: []