import _ from 'lodash' /* eslint-disable camelcase, max-len, no-return-assign, no-unused-vars, */ // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* * decaffeinate suggestions: * DS101: Remove unnecessary use of Array.from * DS102: Remove unnecessary code created because of implicit returns * DS103: Rewrite code to no longer use __guard__ * DS205: Consider reworking code to avoid use of IIFEs * DS206: Consider reworking classes to avoid initClass * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ import moment from 'moment' import ColorManager from '../colors/ColorManager' import displayNameForUser from './util/displayNameForUser' import HistoryViewModes from './util/HistoryViewModes' import './controllers/HistoryV2ListController' import './controllers/HistoryV2FileTreeController' import './controllers/HistoryV2ToolbarController' import './controllers/HistoryV2AddLabelModalController' import './controllers/HistoryV2DeleteLabelModalController' import './directives/infiniteScroll' import './directives/historyDraggableBoundary' import './directives/historyDroppableArea' import './components/historyEntriesList' import './components/historyEntry' import './components/historyLabelsList' import './components/historyLabel' import './components/historyFileTree' import './components/historyFileEntity' import { paywallPrompt } from '../../../../frontend/js/main/account-upgrade' let HistoryManager export default HistoryManager = (function () { HistoryManager = class HistoryManager { static initClass() { this.prototype.MAX_RECENT_UPDATES_TO_SELECT = 5 this.prototype.BATCH_SIZE = 10 } constructor(ide, $scope, localStorage) { this.labelCurrentVersion = this.labelCurrentVersion.bind(this) this.deleteLabel = this.deleteLabel.bind(this) this._addLabelLocally = this._addLabelLocally.bind(this) this.ide = ide this.$scope = $scope this.localStorage = localStorage this.$scope.HistoryViewModes = HistoryViewModes this._localStorageViewModeProjKey = `history.userPrefs.viewMode.${$scope.project_id}` this._localStorageShowOnlyLabelsProjKey = `history.userPrefs.showOnlyLabels.${$scope.project_id}` this._previouslySelectedPathname = null this._loadFileTreeRequestCanceller = null this.hardReset() this.$scope.toggleHistory = () => { if (this.$scope.ui.view === 'history') { this.hide() } else { this.show() this._handleHistoryUIStateChange() } this.ide.$timeout(() => { this.$scope.$broadcast('history:toggle') }, 0) } this.$scope.isHistoryLoading = () => { const selection = this.$scope.history.selection return ( this.$scope.history.loadingFileTree || (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME && selection.file && selection.file.loading) || (this.$scope.history.viewMode === HistoryViewModes.COMPARE && selection.diff && selection.diff.loading) ) } } show() { this.$scope.ui.view = 'history' this.hardReset() if (this.$scope.history.showOnlyLabels) { this.fetchNextBatchOfUpdates() } } hide() { this.$scope.ui.view = 'editor' } _getViewModeUserPref() { return ( this.localStorage(this._localStorageViewModeProjKey) || HistoryViewModes.POINT_IN_TIME ) } _getShowOnlyLabelsUserPref() { return this.localStorage(this._localStorageShowOnlyLabelsProjKey) || false } _setViewModeUserPref(viewModeUserPref) { if ( viewModeUserPref === HistoryViewModes.POINT_IN_TIME || viewModeUserPref === HistoryViewModes.COMPARE ) { this.localStorage(this._localStorageViewModeProjKey, viewModeUserPref) } } _setShowOnlyLabelsUserPref(showOnlyLabelsUserPref) { this.localStorage( this._localStorageShowOnlyLabelsProjKey, !!showOnlyLabelsUserPref ) } hardReset() { this.$scope.history = { isV2: true, updates: [], viewMode: this._getViewModeUserPref(), nextBeforeTimestamp: null, loading: false, atEnd: false, userHasFullFeature: undefined, freeHistoryLimitHit: false, selection: { docs: {}, pathname: null, range: { fromV: null, toV: null, }, hoveredRange: { fromV: null, toV: null, }, diff: null, files: [], file: null, }, error: null, showOnlyLabels: this._getShowOnlyLabelsUserPref(), labels: null, loadingFileTree: true, } const _deregisterFeatureWatcher = this.$scope.$watch( 'project.features.versioning', hasVersioning => { if (hasVersioning != null) { this.$scope.history.userHasFullFeature = hasVersioning if (this.$scope.user.isAdmin) { this.$scope.history.userHasFullFeature = true } _deregisterFeatureWatcher() } } ) } softReset() { this.$scope.history.viewMode = this._getViewModeUserPref() this.$scope.history.selection = { docs: {}, pathname: null, range: { fromV: null, toV: null, }, hoveredRange: { fromV: null, toV: null, }, diff: null, // When history.viewMode == HistoryViewModes.COMPARE files: [], // When history.viewMode == HistoryViewModes.COMPARE file: null, } this.$scope.history.error = null this.$scope.history.showOnlyLabels = this._getShowOnlyLabelsUserPref() } toggleHistoryViewMode() { if (this.$scope.history.viewMode === HistoryViewModes.COMPARE) { this.softReset() this.$scope.history.viewMode = HistoryViewModes.POINT_IN_TIME this._setViewModeUserPref(HistoryViewModes.POINT_IN_TIME) } else { this.softReset() this.$scope.history.viewMode = HistoryViewModes.COMPARE this._setViewModeUserPref(HistoryViewModes.COMPARE) } this._handleHistoryUIStateChange() this.ide.$timeout(() => { this.$scope.$broadcast('history:toggle') }, 0) } _handleHistoryUIStateChange() { if (this.$scope.history.viewMode === HistoryViewModes.COMPARE) { if (this.$scope.history.showOnlyLabels) { this.autoSelectLabelsForComparison() } else { this.autoSelectRecentUpdates() } } else { // Point-in-time mode if (this.$scope.history.showOnlyLabels) { this.autoSelectLabelForPointInTime() } else { this.autoSelectVersionForPointInTime() } } } setHoverFrom(fromV) { const selection = this.$scope.history.selection selection.hoveredRange.fromV = fromV selection.hoveredRange.toV = selection.range.toV this.$scope.history.hoveringOverListSelectors = true } setHoverTo(toV) { const selection = this.$scope.history.selection selection.hoveredRange.toV = toV selection.hoveredRange.fromV = selection.range.fromV this.$scope.history.hoveringOverListSelectors = true } resetHover() { const selection = this.$scope.history.selection selection.hoveredRange.toV = null selection.hoveredRange.fromV = null this.$scope.history.hoveringOverListSelectors = false } showAllUpdates() { if (this.$scope.history.showOnlyLabels) { this.$scope.history.showOnlyLabels = false this._setShowOnlyLabelsUserPref(false) this._handleHistoryUIStateChange() } } showOnlyLabels() { if (!this.$scope.history.showOnlyLabels) { this.$scope.history.showOnlyLabels = true this._setShowOnlyLabelsUserPref(true) this._handleHistoryUIStateChange() } } restoreFile(version, pathname) { const url = `/project/${this.$scope.project_id}/restore_file` return this.ide.$http.post(url, { version, pathname, _csrf: window.csrfToken, }) } loadFileTreeForVersion(version) { return this._loadFileTree(version, version) } loadFileTreeDiff(toV, fromV) { return this._loadFileTree(toV, fromV) } _loadFileTree(toV, fromV) { let url = `/project/${this.$scope.project_id}/filetree/diff` const selection = this.$scope.history.selection const query = [`from=${fromV}`, `to=${toV}`] url += `?${query.join('&')}` this.$scope.$applyAsync( () => (this.$scope.history.loadingFileTree = true) ) selection.file = null selection.pathname = null // If `this._loadFileTreeRequestCanceller` is not null, then we have a request inflight if (this._loadFileTreeRequestCanceller != null) { // Resolving it will cancel the inflight request (or, rather, ignore its result) this._loadFileTreeRequestCanceller.resolve() } this._loadFileTreeRequestCanceller = this.ide.$q.defer() return this.ide.$http .get(url, { timeout: this._loadFileTreeRequestCanceller.promise }) .then(response => { this.$scope.history.selection.files = response.data.diff for (const file of this.$scope.history.selection.files) { if (file.newPathname != null) { file.oldPathname = file.pathname file.pathname = file.newPathname delete file.newPathname } } this._loadFileTreeRequestCanceller = null this.$scope.history.loadingFileTree = false this.autoSelectFile() }) .catch(err => { if (err.status !== -1) { this._loadFileTreeRequestCanceller = null } else { this.$scope.history.loadingFileTree = false } }) } selectFile(file) { if (file != null && file.pathname != null) { this.$scope.history.selection.pathname = this._previouslySelectedPathname = file.pathname this.$scope.history.selection.file = file if (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME) { this.loadFileAtPointInTime() } else { this.reloadDiff() } } } autoSelectFile() { const selectedPathname = null const files = this.$scope.history.selection.files let fileToSelect = null let previouslySelectedFile = null let previouslySelectedFileHasOp = false const filesWithOps = this._getFilesWithOps() const orderedOpTypes = ['edited', 'added', 'renamed', 'removed'] if (this._previouslySelectedPathname != null) { previouslySelectedFile = _.find(files, { pathname: this._previouslySelectedPathname, }) previouslySelectedFileHasOp = _.some(filesWithOps, { pathname: this._previouslySelectedPathname, }) } if (previouslySelectedFile != null && previouslySelectedFileHasOp) { fileToSelect = previouslySelectedFile } else { for (const opType of orderedOpTypes) { const fileWithMatchingOpType = _.find(filesWithOps, { operation: opType, }) if (fileWithMatchingOpType != null) { fileToSelect = _.find(files, { pathname: fileWithMatchingOpType.pathname, }) break } } if (fileToSelect == null) { if (previouslySelectedFile != null) { fileToSelect = previouslySelectedFile } else { const mainFile = _.find(files, function (file) { return /main\.tex$/.test(file.pathname) }) if (mainFile != null) { fileToSelect = mainFile } else { const anyTeXFile = _.find(files, function (file) { return /\.tex$/.test(file.pathname) }) if (anyTeXFile != null) { fileToSelect = anyTeXFile } else { fileToSelect = files[0] } } } } } this.selectFile(fileToSelect) } _getFilesWithOps() { let filesWithOps if (this.$scope.history.viewMode === HistoryViewModes.POINT_IN_TIME) { const currentUpdate = this.getUpdateForVersion( this.$scope.history.selection.range.toV ) filesWithOps = [] if (currentUpdate != null) { for (const pathname of currentUpdate.pathnames) { filesWithOps.push({ pathname, operation: 'edited', }) } for (const op of currentUpdate.project_ops) { let fileWithOp if (op.add != null) { fileWithOp = { pathname: op.add.pathname, operation: 'added', } } else if (op.remove != null) { fileWithOp = { pathname: op.remove.pathname, operation: 'removed', } } else if (op.rename != null) { fileWithOp = { pathname: op.rename.newPathname, operation: 'renamed', } } if (fileWithOp != null) { filesWithOps.push(fileWithOp) } } } } else { filesWithOps = _.reduce( this.$scope.history.selection.files, (curFilesWithOps, file) => { if (file.operation) { curFilesWithOps.push({ pathname: file.pathname, operation: file.operation, }) } return curFilesWithOps }, [] ) } return filesWithOps } autoSelectRecentUpdates() { if (this.$scope.history.updates.length === 0) { return } const toV = this.$scope.history.updates[0].toV let fromV = null let indexOfLastUpdateNotByMe = 0 for (let i = 0; i < this.$scope.history.updates.length; i++) { const update = this.$scope.history.updates[i] if ( this._updateContainsUserId(update, this.$scope.user.id) || i > this.MAX_RECENT_UPDATES_TO_SELECT ) { break } indexOfLastUpdateNotByMe = i } fromV = this.$scope.history.updates[indexOfLastUpdateNotByMe].fromV this.selectVersionsForCompare(toV, fromV) } autoSelectVersionForPointInTime() { if (this.$scope.history.updates.length === 0) { return } let versionToSelect = this.$scope.history.updates[0].toV const range = this.$scope.history.selection.range if ( range.toV != null && range.fromV != null && range.toV === range.fromV ) { versionToSelect = range.toV } this.selectVersionForPointInTime(versionToSelect) } autoSelectLastLabel() { if ( this.$scope.history.labels == null || this.$scope.history.labels.length === 0 ) { return } return this.selectLabelForPointInTime(this.$scope.history.labels[0]) } expandSelectionToVersion(version) { if (version > this.$scope.history.selection.range.toV) { this.$scope.history.selection.range.toV = version } else if (version < this.$scope.history.selection.range.fromV) { this.$scope.history.selection.range.fromV = version } } selectVersionForPointInTime(version) { const selection = this.$scope.history.selection if ( selection.range.toV !== version && selection.range.fromV !== version ) { selection.range.toV = version selection.range.fromV = version this.loadFileTreeForVersion(version) } } selectVersionsForCompare(toV, fromV) { const range = this.$scope.history.selection.range if (range.toV !== toV || range.fromV !== fromV) { range.toV = toV range.fromV = fromV this.loadFileTreeDiff(toV, fromV) } } autoSelectLabelForPointInTime() { const selectedUpdate = this.getUpdateForVersion( this.$scope.history.selection.range.toV ) let nSelectedLabels = 0 if (selectedUpdate != null && selectedUpdate.labels != null) { nSelectedLabels = selectedUpdate.labels.length } // If the currently selected update has no labels, select the last one (version-wise) if (nSelectedLabels === 0) { this.autoSelectLastLabel() // If the update has one label, select it } else if (nSelectedLabels === 1) { this.selectLabelForPointInTime(selectedUpdate.labels[0]) // If there are multiple labels for the update, select the latest } else if (nSelectedLabels > 1) { const sortedLabels = this.ide.$filter('orderBy')( selectedUpdate.labels, '-created_at' ) const lastLabelFromUpdate = sortedLabels[0] this.selectLabelForPointInTime(lastLabelFromUpdate) } } selectLabelForPointInTime(labelToSelect) { let updateToSelect = null if (this._isLabelSelected(labelToSelect)) { // Label already selected return } for (const update of Array.from(this.$scope.history.updates)) { if (update.toV === labelToSelect.version) { updateToSelect = update break } } if (updateToSelect != null) { this.selectVersionForPointInTime(updateToSelect.toV) } else { const selection = this.$scope.history.selection selection.range.toV = labelToSelect.version selection.range.fromV = labelToSelect.version this.loadFileTreeForVersion(labelToSelect.version) } } getUpdateForVersion(version) { for (const update of this.$scope.history.updates) { if (update.toV === version) { return update } } } autoSelectLabelsForComparison() { const labels = this.$scope.history.labels let nLabels = 0 if (Array.isArray(labels)) { nLabels = labels.length } if (nLabels === 0) { // TODO better handling } else if (nLabels === 1) { this.selectVersionsForCompare(labels[0].version, labels[0].version) } else if (nLabels > 1) { this.selectVersionsForCompare(labels[0].version, labels[1].version) } } fetchNextBatchOfUpdates() { if (this.$scope.history.atEnd) { return } let updatesURL = `/project/${this.ide.project_id}/updates?min_count=${this.BATCH_SIZE}` if (this.$scope.history.nextBeforeTimestamp != null) { updatesURL += `&before=${this.$scope.history.nextBeforeTimestamp}` } const labelsURL = `/project/${this.ide.project_id}/labels` const requests = { updates: this.ide.$http.get(updatesURL) } if (this.$scope.history.labels == null) { requests.labels = this.ide.$http.get(labelsURL) } this.$scope.history.loading = true return this.ide.$q .all(requests) .then(response => { this.$scope.history.loading = false const updatesData = response.updates.data let lastUpdateToV = null if (updatesData.updates.length > 0) { lastUpdateToV = updatesData.updates[0].toV } if (response.labels != null) { this.$scope.history.labels = this._loadLabels( response.labels.data, lastUpdateToV ) } this._loadUpdates(updatesData.updates) this.$scope.history.nextBeforeTimestamp = updatesData.nextBeforeTimestamp if ( updatesData.nextBeforeTimestamp == null || this.$scope.history.freeHistoryLimitHit || this.$scope.history.updates.length === 0 ) { this.$scope.history.atEnd = true } if (this.$scope.history.updates.length === 0) { this.$scope.history.loadingFileTree = false } }) .catch(error => { this.$scope.history.loading = false const { status, statusText } = error this.$scope.history.error = { status, statusText } this.$scope.history.atEnd = true this.$scope.history.loadingFileTree = false }) } _loadLabels(labels, lastUpdateToV) { const sortedLabels = this._sortLabelsByVersionAndDate(labels) const labelsWithoutPseudoLabel = this._deletePseudoCurrentStateLabelIfExistent(sortedLabels) const labelsWithPseudoLabelIfNeeded = this._addPseudoCurrentStateLabelIfNeeded( labelsWithoutPseudoLabel, lastUpdateToV ) return labelsWithPseudoLabelIfNeeded } _deletePseudoCurrentStateLabelIfExistent(labels) { if (labels.length && labels[0].isPseudoCurrentStateLabel) { labels.shift() } return labels } _addPseudoCurrentStateLabelIfNeeded(labels, mostRecentVersion) { if ( (labels.length && labels[0].version !== mostRecentVersion) || labels.length === 0 ) { const pseudoCurrentStateLabel = { id: '1', isPseudoCurrentStateLabel: true, version: mostRecentVersion, created_at: new Date().toISOString(), } labels.unshift(pseudoCurrentStateLabel) } return labels } _sortLabelsByVersionAndDate(labels) { return this.ide.$filter('orderBy')(labels, [ 'isPseudoCurrentStateLabel', '-version', '-created_at', ]) } loadFileAtPointInTime() { const toV = this.$scope.history.selection.range.toV const { pathname } = this.$scope.history.selection if (toV == null) { return } let url = `/project/${this.$scope.project_id}/diff` const query = [ `pathname=${encodeURIComponent(pathname)}`, `from=${toV}`, `to=${toV}`, ] url += `?${query.join('&')}` this.$scope.history.selection.file.loading = true return this.ide.$http .get(url) .then(response => { const { text, binary } = this._parseDiff(response.data.diff) this.$scope.history.selection.file.binary = binary this.$scope.history.selection.file.text = text this.$scope.history.selection.file.loading = false }) .catch(function () {}) } reloadDiff() { let { diff } = this.$scope.history.selection const { range, pathname } = this.$scope.history.selection const { fromV, toV } = range if (pathname == null) { this.$scope.history.selection.diff = null return } if ( diff != null && diff.pathname === pathname && diff.fromV === fromV && diff.toV === toV ) { return } this.$scope.history.selection.diff = diff = { fromV, toV, pathname, error: false, } diff.loading = true let url = `/project/${this.$scope.project_id}/diff` const query = [`pathname=${encodeURIComponent(pathname)}`] if (diff.fromV != null && diff.toV != null) { query.push(`from=${diff.fromV}`, `to=${diff.toV}`) } url += `?${query.join('&')}` return this.ide.$http .get(url) .then(response => { const { data } = response diff.loading = false const { text, highlights, binary } = this._parseDiff(data.diff) diff.binary = binary diff.text = text diff.highlights = highlights }) .catch(function () { diff.loading = false diff.error = true }) } labelCurrentVersion(labelComment) { return this._labelVersion( labelComment, this.$scope.history.selection.range.toV ) } deleteLabel(label) { const url = `/project/${this.$scope.project_id}/labels/${label.id}` return this.ide .$http({ url, method: 'DELETE', headers: { 'X-CSRF-Token': window.csrfToken, }, }) .then(response => { return this._deleteLabelLocally(label) }) } _deleteLabelLocally(labelToDelete) { for (let i = 0; i < this.$scope.history.updates.length; i++) { const update = this.$scope.history.updates[i] if (update.toV === labelToDelete.version) { update.labels = _.filter( update.labels, label => label.id !== labelToDelete.id ) break } } this.$scope.history.labels = this._loadLabels( _.filter( this.$scope.history.labels, label => label.id !== labelToDelete.id ), this.$scope.history.updates[0].toV ) this._handleHistoryUIStateChange() } _isLabelSelected(label) { if (label) { return ( label.version <= this.$scope.history.selection.range.toV && label.version >= this.$scope.history.selection.range.fromV ) } else { return false } } _parseDiff(diff) { if (diff.binary) { return { binary: true } } let row = 0 let column = 0 const highlights = [] let text = '' const iterable = diff || [] for (let i = 0; i < iterable.length; i++) { let endColumn, endRow const entry = iterable[i] let content = entry.u || entry.i || entry.d if (!content) { content = '' } text += content const lines = content.split('\n') const startRow = row const 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 const range = { start: { row: startRow, column: startColumn, }, end: { row: endRow, column: endColumn, }, } if (entry.i != null || entry.d != null) { const user = entry.meta.users != null ? entry.meta.users[0] : undefined const name = displayNameForUser(user) const date = moment(entry.meta.end_ts).format('Do MMM YYYY, h:mm a') if (entry.i != null) { highlights.push({ label: `Added by ${name} on ${date}`, highlight: range, hue: ColorManager.getHueForUserId( user != null ? user.id : undefined ), }) } else if (entry.d != null) { highlights.push({ label: `Deleted by ${name} on ${date}`, strikeThrough: range, hue: ColorManager.getHueForUserId( user != null ? user.id : undefined ), }) } } } return { text, highlights } } _loadUpdates(updates) { if (updates == null) { updates = [] } let previousUpdate = this.$scope.history.updates[this.$scope.history.updates.length - 1] const dateTimeNow = new Date() const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1) let cutOffIndex = null const iterable = updates || [] for (let i = 0; i < iterable.length; i++) { const update = iterable[i] for (const user of Array.from(update.meta.users || [])) { if (user != null) { user.hue = ColorManager.getHueForUserId(user.id) } } if ( previousUpdate == null || !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day') ) { update.meta.first_in_day = true } previousUpdate = update if ( !this.$scope.history.userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo ) { cutOffIndex = i || 1 // Make sure that we show at least one entry (to allow labelling). this.$scope.history.freeHistoryLimitHit = true if (this.$scope.project.owner._id === this.$scope.user.id) { ga( 'send', 'event', 'subscription-funnel', 'editor-click-feature', 'history' ) paywallPrompt('history') } break } } const firstLoad = this.$scope.history.updates.length === 0 if (!this.$scope.history.userHasFullFeature && cutOffIndex != null) { updates = updates.slice(0, cutOffIndex) } this.$scope.history.updates = this.$scope.history.updates.concat(updates) if (firstLoad) { this._handleHistoryUIStateChange() } } _labelVersion(comment, version) { const url = `/project/${this.$scope.project_id}/labels` return this.ide.$http .post(url, { comment, version, _csrf: window.csrfToken, }) .then(response => { return this._addLabelLocally(response.data) }) } _addLabelLocally(label) { const localUpdate = _.find( this.$scope.history.updates, update => update.toV === label.version ) if (localUpdate != null) { localUpdate.labels = localUpdate.labels.concat(label) } this.$scope.history.labels = this._loadLabels( this.$scope.history.labels.concat(label), this.$scope.history.updates[0].toV ) this._handleHistoryUIStateChange() } _updateContainsUserId(update, user_id) { for (const user of Array.from(update.meta.users)) { if ((user != null ? user.id : undefined) === user_id) { return true } } return false } } HistoryManager.initClass() return HistoryManager })() function __guard__(value, transform) { return typeof value !== 'undefined' && value !== null ? transform(value) : undefined }