|  | /** | 
|  | * @license | 
|  | * Copyright (C) 2016 The Android Open Source Project | 
|  | * | 
|  | * Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | * you may not use this file except in compliance with the License. | 
|  | * You may obtain a copy of the License at | 
|  | * | 
|  | * http://www.apache.org/licenses/LICENSE-2.0 | 
|  | * | 
|  | * Unless required by applicable law or agreed to in writing, software | 
|  | * distributed under the License is distributed on an "AS IS" BASIS, | 
|  | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | * See the License for the specific language governing permissions and | 
|  | * limitations under the License. | 
|  | */ | 
|  | (function() { | 
|  | 'use strict'; | 
|  |  | 
|  | const CHANGE_ID_ERROR = { | 
|  | MISMATCH: 'mismatch', | 
|  | MISSING: 'missing', | 
|  | }; | 
|  | const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm; | 
|  |  | 
|  | const MIN_LINES_FOR_COMMIT_COLLAPSE = 30; | 
|  | const DEFAULT_NUM_FILES_SHOWN = 200; | 
|  |  | 
|  | const REVIEWERS_REGEX = /^(R|CC)=/gm; | 
|  | const MIN_CHECK_INTERVAL_SECS = 0; | 
|  |  | 
|  | // These are the same as the breakpoint set in CSS. Make sure both are changed | 
|  | // together. | 
|  | const BREAKPOINT_RELATED_SMALL = '50em'; | 
|  | const BREAKPOINT_RELATED_MED = '75em'; | 
|  |  | 
|  | // In the event that the related changes medium width calculation is too close | 
|  | // to zero, provide some height. | 
|  | const MINIMUM_RELATED_MAX_HEIGHT = 100; | 
|  |  | 
|  | const SMALL_RELATED_HEIGHT = 400; | 
|  |  | 
|  | const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500; | 
|  |  | 
|  | const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm; | 
|  |  | 
|  | const ReloadToastMessage = { | 
|  | NEWER_REVISION: 'A newer patch set has been uploaded', | 
|  | RESTORED: 'This change has been restored', | 
|  | ABANDONED: 'This change has been abandoned', | 
|  | MERGED: 'This change has been merged', | 
|  | NEW_MESSAGE: 'There are new messages on this change', | 
|  | }; | 
|  |  | 
|  | const DiffViewMode = { | 
|  | SIDE_BY_SIDE: 'SIDE_BY_SIDE', | 
|  | UNIFIED: 'UNIFIED_DIFF', | 
|  | }; | 
|  |  | 
|  | const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded'; | 
|  | const SEND_REPLY_TIMING_LABEL = 'SendReply'; | 
|  |  | 
|  | Polymer({ | 
|  | is: 'gr-change-view', | 
|  |  | 
|  | /** | 
|  | * Fired when the title of the page should change. | 
|  | * | 
|  | * @event title-change | 
|  | */ | 
|  |  | 
|  | /** | 
|  | * Fired if an error occurs when fetching the change data. | 
|  | * | 
|  | * @event page-error | 
|  | */ | 
|  |  | 
|  | /** | 
|  | * Fired if being logged in is required. | 
|  | * | 
|  | * @event show-auth-required | 
|  | */ | 
|  |  | 
|  | properties: { | 
|  | /** | 
|  | * URL params passed from the router. | 
|  | */ | 
|  | params: { | 
|  | type: Object, | 
|  | observer: '_paramsChanged', | 
|  | }, | 
|  | /** @type {?} */ | 
|  | viewState: { | 
|  | type: Object, | 
|  | notify: true, | 
|  | value() { return {}; }, | 
|  | observer: '_viewStateChanged', | 
|  | }, | 
|  | backPage: String, | 
|  | hasParent: Boolean, | 
|  | keyEventTarget: { | 
|  | type: Object, | 
|  | value() { return document.body; }, | 
|  | }, | 
|  | disableEdit: { | 
|  | type: Boolean, | 
|  | value: false, | 
|  | }, | 
|  | _commentThreads: Array, | 
|  | /** @type {?} */ | 
|  | _serverConfig: { | 
|  | type: Object, | 
|  | observer: '_startUpdateCheckTimer', | 
|  | }, | 
|  | _diffPrefs: Object, | 
|  | _numFilesShown: { | 
|  | type: Number, | 
|  | value: DEFAULT_NUM_FILES_SHOWN, | 
|  | observer: '_numFilesShownChanged', | 
|  | }, | 
|  | _account: { | 
|  | type: Object, | 
|  | value: {}, | 
|  | }, | 
|  | /** @type {?} */ | 
|  | _changeComments: Object, | 
|  | _canStartReview: { | 
|  | type: Boolean, | 
|  | computed: '_computeCanStartReview(_change)', | 
|  | }, | 
|  | _comments: Object, | 
|  | /** @type {?} */ | 
|  | _change: { | 
|  | type: Object, | 
|  | observer: '_changeChanged', | 
|  | }, | 
|  | /** @type {?} */ | 
|  | _commitInfo: Object, | 
|  | _files: Object, | 
|  | _changeNum: String, | 
|  | _diffDrafts: { | 
|  | type: Object, | 
|  | value() { return {}; }, | 
|  | }, | 
|  | _editingCommitMessage: { | 
|  | type: Boolean, | 
|  | value: false, | 
|  | }, | 
|  | _hideEditCommitMessage: { | 
|  | type: Boolean, | 
|  | computed: '_computeHideEditCommitMessage(_loggedIn, ' + | 
|  | '_editingCommitMessage, _change, _editMode)', | 
|  | }, | 
|  | _diffAgainst: String, | 
|  | /** @type {?string} */ | 
|  | _latestCommitMessage: { | 
|  | type: String, | 
|  | value: '', | 
|  | }, | 
|  | _lineHeight: Number, | 
|  | _changeIdCommitMessageError: { | 
|  | type: String, | 
|  | computed: | 
|  | '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)', | 
|  | }, | 
|  | /** @type {?} */ | 
|  | _patchRange: { | 
|  | type: Object, | 
|  | }, | 
|  | _filesExpanded: String, | 
|  | _basePatchNum: String, | 
|  | _selectedRevision: Object, | 
|  | _currentRevisionActions: Object, | 
|  | _allPatchSets: { | 
|  | type: Array, | 
|  | computed: 'computeAllPatchSets(_change, _change.revisions.*)', | 
|  | }, | 
|  | _loggedIn: { | 
|  | type: Boolean, | 
|  | value: false, | 
|  | }, | 
|  | _loading: Boolean, | 
|  | /** @type {?} */ | 
|  | _projectConfig: Object, | 
|  | _rebaseOnCurrent: Boolean, | 
|  | _replyButtonLabel: { | 
|  | type: String, | 
|  | value: 'Reply', | 
|  | computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)', | 
|  | }, | 
|  | _selectedPatchSet: String, | 
|  | _shownFileCount: Number, | 
|  | _initialLoadComplete: { | 
|  | type: Boolean, | 
|  | value: false, | 
|  | }, | 
|  | _replyDisabled: { | 
|  | type: Boolean, | 
|  | value: true, | 
|  | computed: '_computeReplyDisabled(_serverConfig)', | 
|  | }, | 
|  | _changeStatus: { | 
|  | type: String, | 
|  | computed: 'changeStatusString(_change)', | 
|  | }, | 
|  | _changeStatuses: { | 
|  | type: String, | 
|  | computed: '_computeChangeStatusChips(_change, _mergeable)', | 
|  | }, | 
|  | _commitCollapsed: { | 
|  | type: Boolean, | 
|  | value: true, | 
|  | }, | 
|  | _relatedChangesCollapsed: { | 
|  | type: Boolean, | 
|  | value: true, | 
|  | }, | 
|  | /** @type {?number} */ | 
|  | _updateCheckTimerHandle: Number, | 
|  | _editMode: { | 
|  | type: Boolean, | 
|  | computed: '_computeEditMode(_patchRange.*, params.*)', | 
|  | }, | 
|  | _showRelatedToggle: { | 
|  | type: Boolean, | 
|  | value: false, | 
|  | observer: '_updateToggleContainerClass', | 
|  | }, | 
|  | _parentIsCurrent: Boolean, | 
|  | _submitEnabled: Boolean, | 
|  |  | 
|  | /** @type {?} */ | 
|  | _mergeable: { | 
|  | type: Boolean, | 
|  | value: undefined, | 
|  | }, | 
|  | _showMessagesView: { | 
|  | type: Boolean, | 
|  | value: true, | 
|  | }, | 
|  | }, | 
|  |  | 
|  | behaviors: [ | 
|  | Gerrit.KeyboardShortcutBehavior, | 
|  | Gerrit.PatchSetBehavior, | 
|  | Gerrit.RESTClientBehavior, | 
|  | ], | 
|  |  | 
|  | listeners: { | 
|  | 'topic-changed': '_handleTopicChanged', | 
|  | // When an overlay is opened in a mobile viewport, the overlay has a full | 
|  | // screen view. When it has a full screen view, we do not want the | 
|  | // background to be scrollable. This will eliminate background scroll by | 
|  | // hiding most of the contents on the screen upon opening, and showing | 
|  | // again upon closing. | 
|  | 'fullscreen-overlay-opened': '_handleHideBackgroundContent', | 
|  | 'fullscreen-overlay-closed': '_handleShowBackgroundContent', | 
|  | 'diff-comments-modified': '_handleReloadCommentThreads', | 
|  | }, | 
|  | observers: [ | 
|  | '_labelsChanged(_change.labels.*)', | 
|  | '_paramsAndChangeChanged(params, _change)', | 
|  | '_patchNumChanged(_patchRange.patchNum)', | 
|  | ], | 
|  |  | 
|  | keyBindings: { | 
|  | 'shift+r': '_handleCapitalRKey', | 
|  | 'a': '_handleAKey', | 
|  | 'd': '_handleDKey', | 
|  | 'm': '_handleMKey', | 
|  | 's': '_handleSKey', | 
|  | 'u': '_handleUKey', | 
|  | 'x': '_handleXKey', | 
|  | 'z': '_handleZKey', | 
|  | ',': '_handleCommaKey', | 
|  | }, | 
|  |  | 
|  | attached() { | 
|  | this._getServerConfig().then(config => { | 
|  | this._serverConfig = config; | 
|  | }); | 
|  |  | 
|  | this._getLoggedIn().then(loggedIn => { | 
|  | this._loggedIn = loggedIn; | 
|  | if (loggedIn) { | 
|  | this.$.restAPI.getAccount().then(acct => { | 
|  | this._account = acct; | 
|  | }); | 
|  | } | 
|  | this._setDiffViewMode(); | 
|  | }); | 
|  |  | 
|  | this.addEventListener('comment-save', this._handleCommentSave.bind(this)); | 
|  | this.addEventListener('comment-refresh', this._reloadDrafts.bind(this)); | 
|  | this.addEventListener('comment-discard', | 
|  | this._handleCommentDiscard.bind(this)); | 
|  | this.addEventListener('editable-content-save', | 
|  | this._handleCommitMessageSave.bind(this)); | 
|  | this.addEventListener('editable-content-cancel', | 
|  | this._handleCommitMessageCancel.bind(this)); | 
|  | this.listen(window, 'scroll', '_handleScroll'); | 
|  | this.listen(document, 'visibilitychange', '_handleVisibilityChange'); | 
|  | }, | 
|  |  | 
|  | detached() { | 
|  | this.unlisten(window, 'scroll', '_handleScroll'); | 
|  | this.unlisten(document, 'visibilitychange', '_handleVisibilityChange'); | 
|  |  | 
|  | if (this._updateCheckTimerHandle) { | 
|  | this._cancelUpdateCheckTimer(); | 
|  | } | 
|  | }, | 
|  |  | 
|  | get messagesList() { | 
|  | return this.$$('gr-messages-list'); | 
|  | }, | 
|  |  | 
|  | get threadList() { | 
|  | return this.$$('gr-thread-list'); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * @param {boolean=} opt_reset | 
|  | */ | 
|  | _setDiffViewMode(opt_reset) { | 
|  | if (!opt_reset && this.viewState.diffViewMode) { return; } | 
|  |  | 
|  | return this.$.restAPI.getPreferences().then( prefs => { | 
|  | if (!this.viewState.diffMode) { | 
|  | this.set('viewState.diffMode', prefs.default_diff_view); | 
|  | } | 
|  | }).then(() => { | 
|  | if (!this.viewState.diffMode) { | 
|  | this.set('viewState.diffMode', 'SIDE_BY_SIDE'); | 
|  | } | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleMKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) { | 
|  | this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED); | 
|  | } else { | 
|  | this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _handleTabChange() { | 
|  | this._showMessagesView = this.$.commentTabs.selected === 0; | 
|  | }, | 
|  |  | 
|  | _handleEditCommitMessage(e) { | 
|  | this._editingCommitMessage = true; | 
|  | this.$.commitMessageEditor.focusTextarea(); | 
|  | }, | 
|  |  | 
|  | _handleCommitMessageSave(e) { | 
|  | // Trim trailing whitespace from each line. | 
|  | const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, ''); | 
|  |  | 
|  | this.$.jsAPI.handleCommitMessage(this._change, message); | 
|  |  | 
|  | this.$.commitMessageEditor.disabled = true; | 
|  | this.$.restAPI.putChangeCommitMessage( | 
|  | this._changeNum, message).then(resp => { | 
|  | this.$.commitMessageEditor.disabled = false; | 
|  | if (!resp.ok) { return; } | 
|  |  | 
|  | this._latestCommitMessage = this._prepareCommitMsgForLinkify( | 
|  | message); | 
|  | this._editingCommitMessage = false; | 
|  | this._reloadWindow(); | 
|  | }).catch(err => { | 
|  | this.$.commitMessageEditor.disabled = false; | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _reloadWindow() { | 
|  | window.location.reload(); | 
|  | }, | 
|  |  | 
|  | _handleCommitMessageCancel(e) { | 
|  | this._editingCommitMessage = false; | 
|  | }, | 
|  |  | 
|  | _computeChangeStatusChips(change, mergeable) { | 
|  | // Show no chips until mergeability is loaded. | 
|  | if (mergeable === null || mergeable === undefined) { return []; } | 
|  |  | 
|  | const options = { | 
|  | includeDerived: true, | 
|  | mergeable: !!mergeable, | 
|  | submitEnabled: this._submitEnabled, | 
|  | }; | 
|  | return this.changeStatuses(change, options); | 
|  | }, | 
|  |  | 
|  | _computeHideEditCommitMessage(loggedIn, editing, change, editMode) { | 
|  | if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED || | 
|  | editMode) { | 
|  | return true; | 
|  | } | 
|  |  | 
|  | return false; | 
|  | }, | 
|  |  | 
|  | _handleReloadCommentThreads() { | 
|  | // Get any new drafts that have been saved in the diff view and show | 
|  | // in the comment thread view. | 
|  | this._reloadDrafts().then(() => { | 
|  | this._commentThreads = this._changeComments.getAllThreadsForChange() | 
|  | .map(c => Object.assign({}, c)); | 
|  | Polymer.dom.flush(); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleReloadDiffComments(e) { | 
|  | // Keeps the file list counts updated. | 
|  | this._reloadDrafts().then(() => { | 
|  | // Get any new drafts that have been saved in the thread view and show | 
|  | // in the diff view. | 
|  | this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId, | 
|  | e.detail.path); | 
|  | Polymer.dom.flush(); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _computeTotalCommentCounts(unresolvedCount, changeComments) { | 
|  | const draftCount = changeComments.computeDraftCount(); | 
|  | const unresolvedString = GrCountStringFormatter.computeString( | 
|  | unresolvedCount, 'unresolved'); | 
|  | const draftString = GrCountStringFormatter.computePluralString( | 
|  | draftCount, 'draft'); | 
|  |  | 
|  | return unresolvedString + | 
|  | // Add a comma and space if both unresolved and draft comments exist. | 
|  | (unresolvedString && draftString ? ', ' : '') + | 
|  | draftString; | 
|  | }, | 
|  |  | 
|  | _handleCommentSave(e) { | 
|  | if (!e.target.comment.__draft) { return; } | 
|  |  | 
|  | const draft = e.target.comment; | 
|  | draft.patch_set = draft.patch_set || this._patchRange.patchNum; | 
|  |  | 
|  | // The use of path-based notification helpers (set, push) can’t be used | 
|  | // because the paths could contain dots in them. A new object must be | 
|  | // created to satisfy Polymer’s dirty checking. | 
|  | // https://github.com/Polymer/polymer/issues/3127 | 
|  | const diffDrafts = Object.assign({}, this._diffDrafts); | 
|  | if (!diffDrafts[draft.path]) { | 
|  | diffDrafts[draft.path] = [draft]; | 
|  | this._diffDrafts = diffDrafts; | 
|  | return; | 
|  | } | 
|  | for (let i = 0; i < this._diffDrafts[draft.path].length; i++) { | 
|  | if (this._diffDrafts[draft.path][i].id === draft.id) { | 
|  | diffDrafts[draft.path][i] = draft; | 
|  | this._diffDrafts = diffDrafts; | 
|  | return; | 
|  | } | 
|  | } | 
|  | diffDrafts[draft.path].push(draft); | 
|  | diffDrafts[draft.path].sort((c1, c2) => { | 
|  | // No line number means that it’s a file comment. Sort it above the | 
|  | // others. | 
|  | return (c1.line || -1) - (c2.line || -1); | 
|  | }); | 
|  | this._diffDrafts = diffDrafts; | 
|  | }, | 
|  |  | 
|  | _handleCommentDiscard(e) { | 
|  | if (!e.target.comment.__draft) { return; } | 
|  |  | 
|  | const draft = e.target.comment; | 
|  | if (!this._diffDrafts[draft.path]) { | 
|  | return; | 
|  | } | 
|  | let index = -1; | 
|  | for (let i = 0; i < this._diffDrafts[draft.path].length; i++) { | 
|  | if (this._diffDrafts[draft.path][i].id === draft.id) { | 
|  | index = i; | 
|  | break; | 
|  | } | 
|  | } | 
|  | if (index === -1) { | 
|  | // It may be a draft that hasn’t been added to _diffDrafts since it was | 
|  | // never saved. | 
|  | return; | 
|  | } | 
|  |  | 
|  | draft.patch_set = draft.patch_set || this._patchRange.patchNum; | 
|  |  | 
|  | // The use of path-based notification helpers (set, push) can’t be used | 
|  | // because the paths could contain dots in them. A new object must be | 
|  | // created to satisfy Polymer’s dirty checking. | 
|  | // https://github.com/Polymer/polymer/issues/3127 | 
|  | const diffDrafts = Object.assign({}, this._diffDrafts); | 
|  | diffDrafts[draft.path].splice(index, 1); | 
|  | if (diffDrafts[draft.path].length === 0) { | 
|  | delete diffDrafts[draft.path]; | 
|  | } | 
|  | this._diffDrafts = diffDrafts; | 
|  | }, | 
|  |  | 
|  | _handleReplyTap(e) { | 
|  | e.preventDefault(); | 
|  | this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY); | 
|  | }, | 
|  |  | 
|  | _handleOpenDiffPrefs() { | 
|  | this.$.fileList.openDiffPrefs(); | 
|  | }, | 
|  |  | 
|  | _handleOpenIncludedInDialog() { | 
|  | this.$.includedInDialog.loadData().then(() => { | 
|  | Polymer.dom.flush(); | 
|  | this.$.includedInOverlay.refit(); | 
|  | }); | 
|  | this.$.includedInOverlay.open(); | 
|  | }, | 
|  |  | 
|  | _handleIncludedInDialogClose(e) { | 
|  | this.$.includedInOverlay.close(); | 
|  | }, | 
|  |  | 
|  | _handleOpenDownloadDialog() { | 
|  | this.$.downloadOverlay.open().then(() => { | 
|  | this.$.downloadOverlay | 
|  | .setFocusStops(this.$.downloadDialog.getFocusStops()); | 
|  | this.$.downloadDialog.focus(); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleDownloadDialogClose(e) { | 
|  | this.$.downloadOverlay.close(); | 
|  | }, | 
|  |  | 
|  | _handleOpenUploadHelpDialog(e) { | 
|  | this.$.uploadHelpOverlay.open(); | 
|  | }, | 
|  |  | 
|  | _handleCloseUploadHelpDialog(e) { | 
|  | this.$.uploadHelpOverlay.close(); | 
|  | }, | 
|  |  | 
|  | _handleMessageReply(e) { | 
|  | const msg = e.detail.message.message; | 
|  | const quoteStr = msg.split('\n').map( | 
|  | line => { return '> ' + line; }).join('\n') + '\n\n'; | 
|  | this.$.replyDialog.quote = quoteStr; | 
|  | this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY); | 
|  | }, | 
|  |  | 
|  | _handleHideBackgroundContent() { | 
|  | this.$.mainContent.classList.add('overlayOpen'); | 
|  | }, | 
|  |  | 
|  | _handleShowBackgroundContent() { | 
|  | this.$.mainContent.classList.remove('overlayOpen'); | 
|  | }, | 
|  |  | 
|  | _handleReplySent(e) { | 
|  | this.$.replyOverlay.close(); | 
|  | this._reload().then(() => { | 
|  | this.$.reporting.timeEnd(SEND_REPLY_TIMING_LABEL); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleReplyCancel(e) { | 
|  | this.$.replyOverlay.close(); | 
|  | }, | 
|  |  | 
|  | _handleReplyAutogrow(e) { | 
|  | // If the textarea resizes, we need to re-fit the overlay. | 
|  | this.debounce('reply-overlay-refit', () => { | 
|  | this.$.replyOverlay.refit(); | 
|  | }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS); | 
|  | }, | 
|  |  | 
|  | _handleShowReplyDialog(e) { | 
|  | let target = this.$.replyDialog.FocusTarget.REVIEWERS; | 
|  | if (e.detail.value && e.detail.value.ccsOnly) { | 
|  | target = this.$.replyDialog.FocusTarget.CCS; | 
|  | } | 
|  | this._openReplyDialog(target); | 
|  | }, | 
|  |  | 
|  | _handleScroll() { | 
|  | this.debounce('scroll', () => { | 
|  | this.viewState.scrollTop = document.body.scrollTop; | 
|  | }, 150); | 
|  | }, | 
|  |  | 
|  | _setShownFiles(e) { | 
|  | this._shownFileCount = e.detail.length; | 
|  | }, | 
|  |  | 
|  | _expandAllDiffs() { | 
|  | this.$.fileList.expandAllDiffs(); | 
|  | }, | 
|  |  | 
|  | _collapseAllDiffs() { | 
|  | this.$.fileList.collapseAllDiffs(); | 
|  | }, | 
|  |  | 
|  | _paramsChanged(value) { | 
|  | // Change the content of the comment tabs back to messages list, but | 
|  | // do not yet change the tab itself. The animation of tab switching will | 
|  | // get messed up if changed here, because it requires the tabs to be on | 
|  | // the streen, and they are hidden shortly after this. The tab switching | 
|  | // animation will happen in post render tasks. | 
|  | this._showMessagesView = true; | 
|  |  | 
|  | if (value.view !== Gerrit.Nav.View.CHANGE) { | 
|  | this._initialLoadComplete = false; | 
|  | return; | 
|  | } | 
|  |  | 
|  | if (value.changeNum && value.project) { | 
|  | this.$.restAPI.setInProjectLookup(value.changeNum, value.project); | 
|  | } | 
|  |  | 
|  | const patchChanged = this._patchRange && | 
|  | (value.patchNum !== undefined && value.basePatchNum !== undefined) && | 
|  | (this._patchRange.patchNum !== value.patchNum || | 
|  | this._patchRange.basePatchNum !== value.basePatchNum); | 
|  |  | 
|  | if (this._changeNum !== value.changeNum) { | 
|  | this._initialLoadComplete = false; | 
|  | } | 
|  |  | 
|  | const patchRange = { | 
|  | patchNum: value.patchNum, | 
|  | basePatchNum: value.basePatchNum || 'PARENT', | 
|  | }; | 
|  |  | 
|  | this.$.fileList.collapseAllDiffs(); | 
|  | this._patchRange = patchRange; | 
|  |  | 
|  | // If the change has already been loaded and the parameter change is only | 
|  | // in the patch range, then don't do a full reload. | 
|  | if (this._initialLoadComplete && patchChanged) { | 
|  | if (patchRange.patchNum == null) { | 
|  | patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets); | 
|  | } | 
|  | this._reloadPatchNumDependentResources().then(() => { | 
|  | this._sendShowChangeEvent(); | 
|  | }); | 
|  | return; | 
|  | } | 
|  |  | 
|  | this._changeNum = value.changeNum; | 
|  | this.$.relatedChanges.clear(); | 
|  |  | 
|  | this._reload(true).then(() => { | 
|  | this._performPostLoadTasks(); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _sendShowChangeEvent() { | 
|  | this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, { | 
|  | change: this._change, | 
|  | patchNum: this._patchRange.patchNum, | 
|  | info: {mergeable: this._mergeable}, | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _performPostLoadTasks() { | 
|  | this._maybeShowReplyDialog(); | 
|  | this._maybeShowRevertDialog(); | 
|  |  | 
|  | this._sendShowChangeEvent(); | 
|  |  | 
|  | // Selected has to be set after the paper-tabs are visible because | 
|  | // the selected underline depends on calculations made by the browser. | 
|  | this.$.commentTabs.selected = 0; | 
|  |  | 
|  | this.async(() => { | 
|  | if (this.viewState.scrollTop) { | 
|  | document.documentElement.scrollTop = | 
|  | document.body.scrollTop = this.viewState.scrollTop; | 
|  | } else { | 
|  | this._maybeScrollToMessage(window.location.hash); | 
|  | } | 
|  | this._initialLoadComplete = true; | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _paramsAndChangeChanged(value) { | 
|  | // If the change number or patch range is different, then reset the | 
|  | // selected file index. | 
|  | const patchRangeState = this.viewState.patchRange; | 
|  | if (this.viewState.changeNum !== this._changeNum || | 
|  | patchRangeState.basePatchNum !== this._patchRange.basePatchNum || | 
|  | patchRangeState.patchNum !== this._patchRange.patchNum) { | 
|  | this._resetFileListViewState(); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _viewStateChanged(viewState) { | 
|  | this._numFilesShown = viewState.numFilesShown ? | 
|  | viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN; | 
|  | }, | 
|  |  | 
|  | _numFilesShownChanged(numFilesShown) { | 
|  | this.viewState.numFilesShown = numFilesShown; | 
|  | }, | 
|  |  | 
|  | _maybeScrollToMessage(hash) { | 
|  | const msgPrefix = '#message-'; | 
|  | if (hash.startsWith(msgPrefix)) { | 
|  | this.messagesList.scrollToMessage(hash.substr(msgPrefix.length)); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _getLocationSearch() { | 
|  | // Not inlining to make it easier to test. | 
|  | return window.location.search; | 
|  | }, | 
|  |  | 
|  | _getUrlParameter(param) { | 
|  | const pageURL = this._getLocationSearch().substring(1); | 
|  | const vars = pageURL.split('&'); | 
|  | for (let i = 0; i < vars.length; i++) { | 
|  | const name = vars[i].split('='); | 
|  | if (name[0] == param) { | 
|  | return name[0]; | 
|  | } | 
|  | } | 
|  | return null; | 
|  | }, | 
|  |  | 
|  | _maybeShowRevertDialog() { | 
|  | Gerrit.awaitPluginsLoaded() | 
|  | .then(this._getLoggedIn.bind(this)) | 
|  | .then(loggedIn => { | 
|  | if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) { | 
|  | // Do not display dialog if not logged-in or the change is not | 
|  | // merged. | 
|  | return; | 
|  | } | 
|  | if (this._getUrlParameter('revert')) { | 
|  | this.$.actions.showRevertDialog(); | 
|  | } | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _maybeShowReplyDialog() { | 
|  | this._getLoggedIn().then(loggedIn => { | 
|  | if (!loggedIn) { return; } | 
|  |  | 
|  | if (this.viewState.showReplyDialog) { | 
|  | this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY); | 
|  | // TODO(kaspern@): Find a better signal for when to call center. | 
|  | this.async(() => { this.$.replyOverlay.center(); }, 100); | 
|  | this.async(() => { this.$.replyOverlay.center(); }, 1000); | 
|  | this.set('viewState.showReplyDialog', false); | 
|  | } | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _resetFileListViewState() { | 
|  | this.set('viewState.selectedFileIndex', 0); | 
|  | this.set('viewState.scrollTop', 0); | 
|  | if (!!this.viewState.changeNum && | 
|  | this.viewState.changeNum !== this._changeNum) { | 
|  | // Reset the diff mode to null when navigating from one change to | 
|  | // another, so that the user's preference is restored. | 
|  | this._setDiffViewMode(true); | 
|  | this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN); | 
|  | } | 
|  | this.set('viewState.changeNum', this._changeNum); | 
|  | this.set('viewState.patchRange', this._patchRange); | 
|  | }, | 
|  |  | 
|  | _changeChanged(change) { | 
|  | if (!change || !this._patchRange || !this._allPatchSets) { return; } | 
|  | this.set('_patchRange.basePatchNum', | 
|  | this._patchRange.basePatchNum || 'PARENT'); | 
|  | this.set('_patchRange.patchNum', | 
|  | this._patchRange.patchNum || | 
|  | this.computeLatestPatchNum(this._allPatchSets)); | 
|  |  | 
|  | // Reset the related changes toggle in the event it was previously | 
|  | // displayed on an earlier change. | 
|  | this._showRelatedToggle = false; | 
|  |  | 
|  | const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')'; | 
|  | this.fire('title-change', {title}); | 
|  | }, | 
|  |  | 
|  | _computeChangeUrl(change) { | 
|  | return Gerrit.Nav.getUrlForChange(change); | 
|  | }, | 
|  |  | 
|  | _computeShowCommitInfo(changeStatus, current_revision) { | 
|  | return changeStatus === 'Merged' && current_revision; | 
|  | }, | 
|  |  | 
|  | _computeMergedCommitInfo(current_revision, revisions) { | 
|  | const rev = revisions[current_revision]; | 
|  | if (!rev || !rev.commit) { return {}; } | 
|  | // CommitInfo.commit is optional. Set commit in all cases to avoid error | 
|  | // in <gr-commit-info>. @see Issue 5337 | 
|  | if (!rev.commit.commit) { rev.commit.commit = current_revision; } | 
|  | return rev.commit; | 
|  | }, | 
|  |  | 
|  | _computeChangeIdClass(displayChangeId) { | 
|  | return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : ''; | 
|  | }, | 
|  |  | 
|  | _computeTitleAttributeWarning(displayChangeId) { | 
|  | if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) { | 
|  | return 'Change-Id mismatch'; | 
|  | } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) { | 
|  | return 'No Change-Id in commit message'; | 
|  | } | 
|  | }, | 
|  |  | 
|  | _computeChangeIdCommitMessageError(commitMessage, change) { | 
|  | if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; } | 
|  |  | 
|  | // Find the last match in the commit message: | 
|  | let changeId; | 
|  | let changeIdArr; | 
|  |  | 
|  | while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) { | 
|  | changeId = changeIdArr[1]; | 
|  | } | 
|  |  | 
|  | if (changeId) { | 
|  | // A change-id is detected in the commit message. | 
|  |  | 
|  | if (changeId === change.change_id) { | 
|  | // The change-id found matches the real change-id. | 
|  | return null; | 
|  | } | 
|  | // The change-id found does not match the change-id. | 
|  | return CHANGE_ID_ERROR.MISMATCH; | 
|  | } | 
|  | // There is no change-id in the commit message. | 
|  | return CHANGE_ID_ERROR.MISSING; | 
|  | }, | 
|  |  | 
|  | _computeLabelNames(labels) { | 
|  | return Object.keys(labels).sort(); | 
|  | }, | 
|  |  | 
|  | _computeLabelValues(labelName, labels) { | 
|  | const result = []; | 
|  | const t = labels[labelName]; | 
|  | if (!t) { return result; } | 
|  | const approvals = t.all || []; | 
|  | for (const label of approvals) { | 
|  | if (label.value && label.value != labels[labelName].default_value) { | 
|  | let labelClassName; | 
|  | let labelValPrefix = ''; | 
|  | if (label.value > 0) { | 
|  | labelValPrefix = '+'; | 
|  | labelClassName = 'approved'; | 
|  | } else if (label.value < 0) { | 
|  | labelClassName = 'notApproved'; | 
|  | } | 
|  | result.push({ | 
|  | value: labelValPrefix + label.value, | 
|  | className: labelClassName, | 
|  | account: label, | 
|  | }); | 
|  | } | 
|  | } | 
|  | return result; | 
|  | }, | 
|  |  | 
|  | _computeReplyButtonLabel(changeRecord, canStartReview) { | 
|  | if (canStartReview) { | 
|  | return 'Start review'; | 
|  | } | 
|  |  | 
|  | const drafts = (changeRecord && changeRecord.base) || {}; | 
|  | const draftCount = Object.keys(drafts).reduce((count, file) => { | 
|  | return count + drafts[file].length; | 
|  | }, 0); | 
|  |  | 
|  | let label = 'Reply'; | 
|  | if (draftCount > 0) { | 
|  | label += ' (' + draftCount + ')'; | 
|  | } | 
|  | return label; | 
|  | }, | 
|  |  | 
|  | _handleAKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { | 
|  | return; | 
|  | } | 
|  | this._getLoggedIn().then(isLoggedIn => { | 
|  | if (!isLoggedIn) { | 
|  | this.fire('show-auth-required'); | 
|  | return; | 
|  | } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleDKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this.$.downloadOverlay.open(); | 
|  | }, | 
|  |  | 
|  | _handleCapitalRKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e)) { return; } | 
|  | e.preventDefault(); | 
|  | Gerrit.Nav.navigateToChange(this._change); | 
|  | }, | 
|  |  | 
|  | _handleSKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this.$.changeStar.toggleStar(); | 
|  | }, | 
|  |  | 
|  | _handleUKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this._determinePageBack(); | 
|  | }, | 
|  |  | 
|  | _handleXKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this.messagesList.handleExpandCollapse(true); | 
|  | }, | 
|  |  | 
|  | _handleZKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this.messagesList.handleExpandCollapse(false); | 
|  | }, | 
|  |  | 
|  | _handleCommaKey(e) { | 
|  | if (this.shouldSuppressKeyboardShortcut(e) || | 
|  | this.modifierPressed(e)) { return; } | 
|  |  | 
|  | e.preventDefault(); | 
|  | this.$.fileList.openDiffPrefs(); | 
|  | }, | 
|  |  | 
|  | _determinePageBack() { | 
|  | // Default backPage to root if user came to change view page | 
|  | // via an email link, etc. | 
|  | Gerrit.Nav.navigateToRelativeUrl(this.backPage || | 
|  | Gerrit.Nav.getUrlForRoot()); | 
|  | }, | 
|  |  | 
|  | _handleLabelRemoved(splices, path) { | 
|  | for (const splice of splices) { | 
|  | for (const removed of splice.removed) { | 
|  | const changePath = path.split('.'); | 
|  | const labelPath = changePath.splice(0, changePath.length - 2); | 
|  | const labelDict = this.get(labelPath); | 
|  | if (labelDict.approved && | 
|  | labelDict.approved._account_id === removed._account_id) { | 
|  | this._reload(); | 
|  | return; | 
|  | } | 
|  | } | 
|  | } | 
|  | }, | 
|  |  | 
|  | _labelsChanged(changeRecord) { | 
|  | if (!changeRecord) { return; } | 
|  | if (changeRecord.value && changeRecord.value.indexSplices) { | 
|  | this._handleLabelRemoved(changeRecord.value.indexSplices, | 
|  | changeRecord.path); | 
|  | } | 
|  | this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, { | 
|  | change: this._change, | 
|  | }); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * @param {string=} opt_section | 
|  | */ | 
|  | _openReplyDialog(opt_section) { | 
|  | this.$.replyOverlay.open().then(() => { | 
|  | this._resetReplyOverlayFocusStops(); | 
|  | this.$.replyDialog.open(opt_section); | 
|  | Polymer.dom.flush(); | 
|  | this.$.replyOverlay.center(); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleReloadChange(e) { | 
|  | return this._reload().then(() => { | 
|  | // If the change was rebased or submitted, we need to reload the page | 
|  | // with the latest patch. | 
|  | const action = e.detail.action; | 
|  | if (action === 'rebase' || action === 'submit') { | 
|  | Gerrit.Nav.navigateToChange(this._change); | 
|  | } | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _handleGetChangeDetailError(response) { | 
|  | this.fire('page-error', {response}); | 
|  | }, | 
|  |  | 
|  | _getLoggedIn() { | 
|  | return this.$.restAPI.getLoggedIn(); | 
|  | }, | 
|  |  | 
|  | _getServerConfig() { | 
|  | return this.$.restAPI.getConfig(); | 
|  | }, | 
|  |  | 
|  | _getProjectConfig() { | 
|  | return this.$.restAPI.getProjectConfig(this._change.project).then( | 
|  | config => { | 
|  | this._projectConfig = config; | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _updateRebaseAction(revisionActions) { | 
|  | if (revisionActions && revisionActions.rebase) { | 
|  | revisionActions.rebase.rebaseOnCurrent = | 
|  | !!revisionActions.rebase.enabled; | 
|  | this._parentIsCurrent = !revisionActions.rebase.enabled; | 
|  | revisionActions.rebase.enabled = true; | 
|  | } else { | 
|  | this._parentIsCurrent = true; | 
|  | } | 
|  | return revisionActions; | 
|  | }, | 
|  |  | 
|  | _prepareCommitMsgForLinkify(msg) { | 
|  | // TODO(wyatta) switch linkify sequence, see issue 5526. | 
|  | // This is a zero-with space. It is added to prevent the linkify library | 
|  | // from including R= or CC= as part of the email address. | 
|  | return msg.replace(REVIEWERS_REGEX, '$1=\u200B'); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Utility function to make the necessary modifications to a change in the | 
|  | * case an edit exists. | 
|  | * | 
|  | * @param {!Object} change | 
|  | * @param {?Object} edit | 
|  | */ | 
|  | _processEdit(change, edit) { | 
|  | if (!edit) { return; } | 
|  | change.revisions[edit.commit.commit] = { | 
|  | _number: this.EDIT_NAME, | 
|  | basePatchNum: edit.base_patch_set_number, | 
|  | commit: edit.commit, | 
|  | fetch: edit.fetch, | 
|  | }; | 
|  | // If the edit is based on the most recent patchset, load it by | 
|  | // default, unless another patch set to load was specified in the URL. | 
|  | if (!this._patchRange.patchNum && | 
|  | change.current_revision === edit.base_revision) { | 
|  | change.current_revision = edit.commit.commit; | 
|  | this._patchRange.patchNum = this.EDIT_NAME; | 
|  | // Because edits are fibbed as revisions and added to the revisions | 
|  | // array, and revision actions are always derived from the 'latest' | 
|  | // patch set, we must copy over actions from the patch set base. | 
|  | // Context: Issue 7243 | 
|  | change.revisions[edit.commit.commit].actions = | 
|  | change.revisions[edit.base_revision].actions; | 
|  | } | 
|  | }, | 
|  |  | 
|  | _getChangeDetail() { | 
|  | const detailCompletes = this.$.restAPI.getChangeDetail( | 
|  | this._changeNum, this._handleGetChangeDetailError.bind(this)); | 
|  | const editCompletes = this._getEdit(); | 
|  |  | 
|  | return Promise.all([detailCompletes, editCompletes]) | 
|  | .then(([change, edit]) => { | 
|  | if (!change) { | 
|  | return ''; | 
|  | } | 
|  | this._processEdit(change, edit); | 
|  | // Issue 4190: Coalesce missing topics to null. | 
|  | if (!change.topic) { change.topic = null; } | 
|  | if (!change.reviewer_updates) { | 
|  | change.reviewer_updates = null; | 
|  | } | 
|  | const latestRevisionSha = this._getLatestRevisionSHA(change); | 
|  | const currentRevision = change.revisions[latestRevisionSha]; | 
|  | if (currentRevision.commit && currentRevision.commit.message) { | 
|  | this._latestCommitMessage = this._prepareCommitMsgForLinkify( | 
|  | currentRevision.commit.message); | 
|  | } else { | 
|  | this._latestCommitMessage = null; | 
|  | } | 
|  |  | 
|  | // Update the submit enabled based on current revision. | 
|  | this._submitEnabled = this._isSubmitEnabled(currentRevision); | 
|  |  | 
|  | const lineHeight = getComputedStyle(this).lineHeight; | 
|  |  | 
|  | // Slice returns a number as a string, convert to an int. | 
|  | this._lineHeight = | 
|  | parseInt(lineHeight.slice(0, lineHeight.length - 2), 10); | 
|  |  | 
|  | this._change = change; | 
|  | if (!this._patchRange || !this._patchRange.patchNum || | 
|  | this.patchNumEquals(this._patchRange.patchNum, | 
|  | currentRevision._number)) { | 
|  | // CommitInfo.commit is optional, and may need patching. | 
|  | if (!currentRevision.commit.commit) { | 
|  | currentRevision.commit.commit = latestRevisionSha; | 
|  | } | 
|  | this._commitInfo = currentRevision.commit; | 
|  | this._currentRevisionActions = | 
|  | this._updateRebaseAction(currentRevision.actions); | 
|  | this._selectedRevision = currentRevision; | 
|  | // TODO: Fetch and process files. | 
|  | } else { | 
|  | this._selectedRevision = | 
|  | Object.values(this._change.revisions).find( | 
|  | revision => revision._number === | 
|  | parseInt(this._patchRange.patchNum, 10)); | 
|  | } | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _isSubmitEnabled(currentRevision) { | 
|  | return !!(currentRevision.actions && currentRevision.actions.submit && | 
|  | currentRevision.actions.submit.enabled); | 
|  | }, | 
|  |  | 
|  | _getEdit() { | 
|  | return this.$.restAPI.getChangeEdit(this._changeNum, true); | 
|  | }, | 
|  |  | 
|  | _getLatestCommitMessage() { | 
|  | return this.$.restAPI.getChangeCommitInfo(this._changeNum, | 
|  | this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => { | 
|  | this._latestCommitMessage = | 
|  | this._prepareCommitMsgForLinkify(commitInfo.message); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _getLatestRevisionSHA(change) { | 
|  | if (change.current_revision) { | 
|  | return change.current_revision; | 
|  | } | 
|  | // current_revision may not be present in the case where the latest rev is | 
|  | // a draft and the user doesn’t have permission to view that rev. | 
|  | let latestRev = null; | 
|  | let latestPatchNum = -1; | 
|  | for (const rev in change.revisions) { | 
|  | if (!change.revisions.hasOwnProperty(rev)) { continue; } | 
|  |  | 
|  | if (change.revisions[rev]._number > latestPatchNum) { | 
|  | latestRev = rev; | 
|  | latestPatchNum = change.revisions[rev]._number; | 
|  | } | 
|  | } | 
|  | return latestRev; | 
|  | }, | 
|  |  | 
|  | _getCommitInfo() { | 
|  | return this.$.restAPI.getChangeCommitInfo( | 
|  | this._changeNum, this._patchRange.patchNum).then( | 
|  | commitInfo => { | 
|  | this._commitInfo = commitInfo; | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _reloadDraftsWithCallback(e) { | 
|  | return this._reloadDrafts().then(() => { | 
|  | return e.detail.resolve(); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Fetches a new changeComment object, and data for all types of comments | 
|  | * (comments, robot comments, draft comments) is requested. | 
|  | */ | 
|  | _reloadComments() { | 
|  | return this.$.commentAPI.loadAll(this._changeNum) | 
|  | .then(comments => { | 
|  | this._changeComments = comments; | 
|  | this._diffDrafts = Object.assign({}, this._changeComments.drafts); | 
|  | this._commentThreads = this._changeComments.getAllThreadsForChange() | 
|  | .map(c => Object.assign({}, c)); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Fetches a new changeComment object, but only updated data for drafts is | 
|  | * requested. | 
|  | */ | 
|  | _reloadDrafts() { | 
|  | return this.$.commentAPI.reloadDrafts(this._changeNum) | 
|  | .then(comments => { | 
|  | this._changeComments = comments; | 
|  | this._diffDrafts = Object.assign({}, this._changeComments.drafts); | 
|  | }); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Reload the change. | 
|  | * @param {boolean=} opt_reloadRelatedChanges Reloads the related chanegs | 
|  | *     when true. | 
|  | * @return {Promise} A promise that resolves when the core data has loaded. | 
|  | *     Some non-core data loading may still be in-flight when the core data | 
|  | *     promise resolves. | 
|  | */ | 
|  | _reload(opt_reloadRelatedChanges) { | 
|  | this._loading = true; | 
|  | this._relatedChangesCollapsed = true; | 
|  |  | 
|  | // Array to house all promises related to data requests. | 
|  | const allDataPromises = []; | 
|  |  | 
|  | // Resolves when the change detail and the edit patch set (if available) | 
|  | // are loaded. | 
|  | const detailCompletes = this._getChangeDetail(); | 
|  | allDataPromises.push(detailCompletes); | 
|  |  | 
|  | // Resolves when the loading flag is set to false, meaning that some | 
|  | // change content may start appearing. | 
|  | const loadingFlagSet = detailCompletes | 
|  | .then(() => { this._loading = false; }); | 
|  |  | 
|  | // Resolves when the project config has loaded. | 
|  | const projectConfigLoaded = detailCompletes | 
|  | .then(() => this._getProjectConfig()); | 
|  | allDataPromises.push(projectConfigLoaded); | 
|  |  | 
|  | // Resolves when change comments have loaded (comments, drafts and robot | 
|  | // comments). | 
|  | const commentsLoaded = this._reloadComments(); | 
|  | allDataPromises.push(commentsLoaded); | 
|  |  | 
|  | let coreDataPromise; | 
|  |  | 
|  | // If the patch number is specified | 
|  | if (this._patchRange.patchNum) { | 
|  | // Because a specific patchset is specified, reload the resources that | 
|  | // are keyed by patch number or patch range. | 
|  | const patchResourcesLoaded = this._reloadPatchNumDependentResources(); | 
|  | allDataPromises.push(patchResourcesLoaded); | 
|  |  | 
|  | // Promise resolves when the change detail and patch dependent resources | 
|  | // have loaded. | 
|  | const detailAndPatchResourcesLoaded = | 
|  | Promise.all([patchResourcesLoaded, loadingFlagSet]); | 
|  |  | 
|  | // Promise resolves when mergeability information has loaded. | 
|  | const mergeabilityLoaded = detailAndPatchResourcesLoaded | 
|  | .then(() => this._getMergeability()); | 
|  | allDataPromises.push(mergeabilityLoaded); | 
|  |  | 
|  | // Promise resovles when the change actions have loaded. | 
|  | const actionsLoaded = detailAndPatchResourcesLoaded | 
|  | .then(() => this.$.actions.reload()); | 
|  | allDataPromises.push(actionsLoaded); | 
|  |  | 
|  | // The core data is loaded when both mergeability and actions are known. | 
|  | coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]); | 
|  | } else { | 
|  | // Resolves when the file list has loaded. | 
|  | const fileListReload = loadingFlagSet | 
|  | .then(() => this.$.fileList.reload()); | 
|  | allDataPromises.push(fileListReload); | 
|  |  | 
|  | const latestCommitMessageLoaded = loadingFlagSet.then(() => { | 
|  | // If the latest commit message is known, there is nothing to do. | 
|  | if (this._latestCommitMessage) { return Promise.resolve(); } | 
|  | return this._getLatestCommitMessage(); | 
|  | }); | 
|  | allDataPromises.push(latestCommitMessageLoaded); | 
|  |  | 
|  | // Promise resolves when mergeability information has loaded. | 
|  | const mergeabilityLoaded = loadingFlagSet | 
|  | .then(() => this._getMergeability()); | 
|  | allDataPromises.push(mergeabilityLoaded); | 
|  |  | 
|  | // Core data is loaded when mergeability has been loaded. | 
|  | coreDataPromise = mergeabilityLoaded; | 
|  | } | 
|  |  | 
|  | if (opt_reloadRelatedChanges) { | 
|  | const relatedChangesLoaded = coreDataPromise | 
|  | .then(() => this.$.relatedChanges.reload()); | 
|  | allDataPromises.push(relatedChangesLoaded); | 
|  | } | 
|  |  | 
|  | this.$.reporting.time(CHANGE_DATA_TIMING_LABEL); | 
|  | Promise.all(allDataPromises).then(() => { | 
|  | this.$.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL); | 
|  | this.$.reporting.changeFullyLoaded(); | 
|  | }); | 
|  |  | 
|  | return coreDataPromise | 
|  | .then(() => { this.$.reporting.changeDisplayed(); }); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Kicks off requests for resources that rely on the patch range | 
|  | * (`this._patchRange`) being defined. | 
|  | */ | 
|  | _reloadPatchNumDependentResources() { | 
|  | return Promise.all([ | 
|  | this._getCommitInfo(), | 
|  | this.$.fileList.reload(), | 
|  | ]); | 
|  | }, | 
|  |  | 
|  | _getMergeability() { | 
|  | // If the change is closed, it is not mergeable. Note: already merged | 
|  | // changes are obviously not mergeable, but the mergeability API will not | 
|  | // answer for abandoned changes. | 
|  | if (this._change.status === this.ChangeStatus.MERGED || | 
|  | this._change.status === this.ChangeStatus.ABANDONED) { | 
|  | this._mergeable = false; | 
|  | return Promise.resolve(); | 
|  | } | 
|  |  | 
|  | this._mergeable = null; | 
|  | return this.$.restAPI.getMergeable(this._changeNum).then(m => { | 
|  | this._mergeable = m.mergeable; | 
|  | }); | 
|  | }, | 
|  |  | 
|  | _computeCanStartReview(change) { | 
|  | return !!(change.actions && change.actions.ready && | 
|  | change.actions.ready.enabled); | 
|  | }, | 
|  |  | 
|  | _computeReplyDisabled() { return false; }, | 
|  |  | 
|  | _computeChangePermalinkAriaLabel(changeNum) { | 
|  | return 'Change ' + changeNum; | 
|  | }, | 
|  |  | 
|  | _computeCommitClass(collapsed, commitMessage) { | 
|  | if (this._computeCommitToggleHidden(commitMessage)) { return ''; } | 
|  | return collapsed ? 'collapsed' : ''; | 
|  | }, | 
|  |  | 
|  | _computeRelatedChangesClass(collapsed) { | 
|  | return collapsed ? 'collapsed' : ''; | 
|  | }, | 
|  |  | 
|  | _computeCollapseText(collapsed) { | 
|  | // Symbols are up and down triangles. | 
|  | return collapsed ? '\u25bc Show more' : '\u25b2 Show less'; | 
|  | }, | 
|  |  | 
|  | _toggleCommitCollapsed() { | 
|  | this._commitCollapsed = !this._commitCollapsed; | 
|  | if (this._commitCollapsed) { | 
|  | window.scrollTo(0, 0); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _toggleRelatedChangesCollapsed() { | 
|  | this._relatedChangesCollapsed = !this._relatedChangesCollapsed; | 
|  | if (this._relatedChangesCollapsed) { | 
|  | window.scrollTo(0, 0); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _computeCommitToggleHidden(commitMessage) { | 
|  | if (!commitMessage) { return true; } | 
|  | return commitMessage.split('\n').length < MIN_LINES_FOR_COMMIT_COLLAPSE; | 
|  | }, | 
|  |  | 
|  | _getOffsetHeight(element) { | 
|  | return element.offsetHeight; | 
|  | }, | 
|  |  | 
|  | _getScrollHeight(element) { | 
|  | return element.scrollHeight; | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * Get the line height of an element to the nearest integer. | 
|  | */ | 
|  | _getLineHeight(element) { | 
|  | const lineHeightStr = getComputedStyle(element).lineHeight; | 
|  | return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2)); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * New max height for the related changes section, shorter than the existing | 
|  | * change info height. | 
|  | */ | 
|  | _updateRelatedChangeMaxHeight() { | 
|  | // Takes into account approximate height for the expand button and | 
|  | // bottom margin. | 
|  | const EXTRA_HEIGHT = 30; | 
|  | let newHeight; | 
|  | const hasCommitToggle = | 
|  | !this._computeCommitToggleHidden(this._latestCommitMessage); | 
|  |  | 
|  | if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`) | 
|  | .matches) { | 
|  | // In a small (mobile) view, give the relation chain some space. | 
|  | newHeight = SMALL_RELATED_HEIGHT; | 
|  | } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`) | 
|  | .matches) { | 
|  | // Since related changes are below the commit message, but still next to | 
|  | // metadata, the height should be the height of the metadata minus the | 
|  | // height of the commit message to reduce jank. However, if that doesn't | 
|  | // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT. | 
|  | // Note: extraHeight is to take into account margin/padding. | 
|  | const medRelatedHeight = Math.max( | 
|  | this._getOffsetHeight(this.$.mainChangeInfo) - | 
|  | this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT, | 
|  | MINIMUM_RELATED_MAX_HEIGHT); | 
|  | newHeight = medRelatedHeight; | 
|  | } else { | 
|  | if (hasCommitToggle) { | 
|  | // Make sure the content is lined up if both areas have buttons. If | 
|  | // the commit message is not collapsed, instead use the change info | 
|  | // height. | 
|  | newHeight = this._getOffsetHeight(this.$.commitMessage); | 
|  | } else { | 
|  | newHeight = this._getOffsetHeight(this.$.commitAndRelated) - | 
|  | EXTRA_HEIGHT; | 
|  | } | 
|  | } | 
|  | const stylesToUpdate = {}; | 
|  |  | 
|  | // Get the line height of related changes, and convert it to the nearest | 
|  | // integer. | 
|  | const lineHeight = this._getLineHeight(this.$.relatedChanges); | 
|  |  | 
|  | // Figure out a new height that is divisible by the rounded line height. | 
|  | const remainder = newHeight % lineHeight; | 
|  | newHeight = newHeight - remainder; | 
|  |  | 
|  | stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px'; | 
|  |  | 
|  | // Update the max-height of the relation chain to this new height. | 
|  | if (hasCommitToggle) { | 
|  | stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px'; | 
|  | } | 
|  |  | 
|  | this.updateStyles(stylesToUpdate); | 
|  | }, | 
|  |  | 
|  | _computeShowRelatedToggle() { | 
|  | // Make sure the max height has been applied, since there is now content | 
|  | // to populate. | 
|  | // TODO update to polymer 2.x syntax | 
|  | if (!this.getComputedStyleValue('--relation-chain-max-height')) { | 
|  | this._updateRelatedChangeMaxHeight(); | 
|  | } | 
|  | // Prevents showMore from showing when click on related change, since the | 
|  | // line height would be positive, but related changes height is 0. | 
|  | if (!this._getScrollHeight(this.$.relatedChanges)) { | 
|  | return this._showRelatedToggle = false; | 
|  | } | 
|  |  | 
|  | if (this._getScrollHeight(this.$.relatedChanges) > | 
|  | (this._getOffsetHeight(this.$.relatedChanges) + | 
|  | this._getLineHeight(this.$.relatedChanges))) { | 
|  | return this._showRelatedToggle = true; | 
|  | } | 
|  | this._showRelatedToggle = false; | 
|  | }, | 
|  |  | 
|  | _updateToggleContainerClass(showRelatedToggle) { | 
|  | if (showRelatedToggle) { | 
|  | this.$.relatedChangesToggle.classList.add('showToggle'); | 
|  | } else { | 
|  | this.$.relatedChangesToggle.classList.remove('showToggle'); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _startUpdateCheckTimer() { | 
|  | if (!this._serverConfig || | 
|  | !this._serverConfig.change || | 
|  | this._serverConfig.change.update_delay === undefined || | 
|  | this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) { | 
|  | return; | 
|  | } | 
|  |  | 
|  | this._updateCheckTimerHandle = this.async(() => { | 
|  | this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => { | 
|  | let toastMessage = null; | 
|  | if (!result.isLatest) { | 
|  | toastMessage = ReloadToastMessage.NEWER_REVISION; | 
|  | } else if (result.newStatus === this.ChangeStatus.MERGED) { | 
|  | toastMessage = ReloadToastMessage.MERGED; | 
|  | } else if (result.newStatus === this.ChangeStatus.ABANDONED) { | 
|  | toastMessage = ReloadToastMessage.ABANDONED; | 
|  | } else if (result.newStatus === this.ChangeStatus.NEW) { | 
|  | toastMessage = ReloadToastMessage.RESTORED; | 
|  | } else if (result.newMessages) { | 
|  | toastMessage = ReloadToastMessage.NEW_MESSAGE; | 
|  | } | 
|  |  | 
|  | if (!toastMessage) { | 
|  | this._startUpdateCheckTimer(); | 
|  | return; | 
|  | } | 
|  |  | 
|  | this._cancelUpdateCheckTimer(); | 
|  | this.fire('show-alert', { | 
|  | message: toastMessage, | 
|  | // Persist this alert. | 
|  | dismissOnNavigation: true, | 
|  | action: 'Reload', | 
|  | callback: function() { | 
|  | // Load the current change without any patch range. | 
|  | Gerrit.Nav.navigateToChange(this._change); | 
|  | }.bind(this), | 
|  | }); | 
|  | }); | 
|  | }, this._serverConfig.change.update_delay * 1000); | 
|  | }, | 
|  |  | 
|  | _cancelUpdateCheckTimer() { | 
|  | if (this._updateCheckTimerHandle) { | 
|  | this.cancelAsync(this._updateCheckTimerHandle); | 
|  | } | 
|  | this._updateCheckTimerHandle = null; | 
|  | }, | 
|  |  | 
|  | _handleVisibilityChange() { | 
|  | if (document.hidden && this._updateCheckTimerHandle) { | 
|  | this._cancelUpdateCheckTimer(); | 
|  | } else if (!this._updateCheckTimerHandle) { | 
|  | this._startUpdateCheckTimer(); | 
|  | } | 
|  | }, | 
|  |  | 
|  | _handleTopicChanged() { | 
|  | this.$.relatedChanges.reload(); | 
|  | }, | 
|  |  | 
|  | _computeHeaderClass(editMode) { | 
|  | const classes = ['header']; | 
|  | if (editMode) { classes.push('editMode'); } | 
|  | return classes.join(' '); | 
|  | }, | 
|  |  | 
|  | _computeEditMode(patchRangeRecord, paramsRecord) { | 
|  | if (paramsRecord.base && paramsRecord.base.edit) { return true; } | 
|  |  | 
|  | const patchRange = patchRangeRecord.base || {}; | 
|  | return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME); | 
|  | }, | 
|  |  | 
|  | _handleFileActionTap(e) { | 
|  | e.preventDefault(); | 
|  | const controls = this.$.fileListHeader.$.editControls; | 
|  | const path = e.detail.path; | 
|  | switch (e.detail.action) { | 
|  | case GrEditConstants.Actions.DELETE.id: | 
|  | controls.openDeleteDialog(path); | 
|  | break; | 
|  | case GrEditConstants.Actions.OPEN.id: | 
|  | Gerrit.Nav.navigateToRelativeUrl( | 
|  | Gerrit.Nav.getEditUrlForDiff(this._change, path, | 
|  | this._patchRange.patchNum)); | 
|  | break; | 
|  | case GrEditConstants.Actions.RENAME.id: | 
|  | controls.openRenameDialog(path); | 
|  | break; | 
|  | case GrEditConstants.Actions.RESTORE.id: | 
|  | controls.openRestoreDialog(path); | 
|  | break; | 
|  | } | 
|  | }, | 
|  |  | 
|  | _computeCommitMessageKey(number, revision) { | 
|  | return `c${number}_rev${revision}`; | 
|  | }, | 
|  |  | 
|  | _patchNumChanged(patchNumStr) { | 
|  | if (!this._selectedRevision) { | 
|  | return; | 
|  | } | 
|  | const patchNum = parseInt(patchNumStr, 10); | 
|  | if (patchNum === this._selectedRevision._number) { | 
|  | return; | 
|  | } | 
|  | this._selectedRevision = Object.values(this._change.revisions).find( | 
|  | revision => revision._number === patchNum); | 
|  | }, | 
|  |  | 
|  | /** | 
|  | * If an edit exists already, load it. Otherwise, toggle edit mode via the | 
|  | * navigation API. | 
|  | */ | 
|  | _handleEditTap() { | 
|  | const editInfo = Object.values(this._change.revisions).find(info => | 
|  | info._number === this.EDIT_NAME); | 
|  |  | 
|  | if (editInfo) { | 
|  | Gerrit.Nav.navigateToChange(this._change, this.EDIT_NAME); | 
|  | return; | 
|  | } | 
|  |  | 
|  | // Avoid putting patch set in the URL unless a non-latest patch set is | 
|  | // selected. | 
|  | let patchNum; | 
|  | if (!this.patchNumEquals(this._patchRange.patchNum, | 
|  | this.computeLatestPatchNum(this._allPatchSets))) { | 
|  | patchNum = this._patchRange.patchNum; | 
|  | } | 
|  | Gerrit.Nav.navigateToChange(this._change, patchNum, null, true); | 
|  | }, | 
|  |  | 
|  | _handleStopEditTap() { | 
|  | Gerrit.Nav.navigateToChange(this._change, this._patchRange.patchNum); | 
|  | }, | 
|  |  | 
|  | _resetReplyOverlayFocusStops() { | 
|  | this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops()); | 
|  | }, | 
|  |  | 
|  | _handleToggleStar(e) { | 
|  | this.$.restAPI.saveChangeStarred(e.detail.change._number, | 
|  | e.detail.starred); | 
|  | }, | 
|  | }); | 
|  | })(); |