| // 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; |
| |
| // Maximum length for patch set descriptions. |
| const PATCH_DESC_MAX_LENGTH = 500; |
| 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 = '60em'; |
| |
| // 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; |
| |
| 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; }, |
| }, |
| /** @type {?} */ |
| _serverConfig: { |
| type: Object, |
| observer: '_startUpdateCheckTimer', |
| }, |
| _diffPrefs: Object, |
| _numFilesShown: { |
| type: Number, |
| value: DEFAULT_NUM_FILES_SHOWN, |
| observer: '_numFilesShownChanged', |
| }, |
| _account: { |
| type: Object, |
| value: {}, |
| }, |
| _canStartReview: { |
| type: Boolean, |
| computed: '_computeCanStartReview(_loggedIn, _change, _account)', |
| }, |
| _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)', |
| }, |
| /** @type {?string} */ |
| _latestCommitMessage: { |
| type: String, |
| value: '', |
| }, |
| _lineHeight: Number, |
| _changeIdCommitMessageError: { |
| type: String, |
| computed: |
| '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)', |
| }, |
| /** @type {?} */ |
| _patchRange: { |
| type: Object, |
| observer: '_updateSelected', |
| }, |
| _relatedChangesLoading: { |
| type: Boolean, |
| value: true, |
| }, |
| _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, |
| _initialLoadComplete: { |
| type: Boolean, |
| value: false, |
| }, |
| _descriptionReadOnly: { |
| type: Boolean, |
| computed: '_computeDescriptionReadOnly(_loggedIn, _change, _account)', |
| }, |
| _replyDisabled: { |
| type: Boolean, |
| value: true, |
| computed: '_computeReplyDisabled(_serverConfig)', |
| }, |
| _changeStatus: { |
| type: String, |
| computed: 'changeStatusString(_change)', |
| }, |
| _commitCollapsed: { |
| type: Boolean, |
| value: true, |
| }, |
| _relatedChangesCollapsed: { |
| type: Boolean, |
| value: true, |
| }, |
| /** @type {?number} */ |
| _updateCheckTimerHandle: Number, |
| _sortedRevisions: Array, |
| _editLoaded: { |
| type: Boolean, |
| computed: '_computeEditLoaded(_patchRange.*)', |
| }, |
| }, |
| |
| behaviors: [ |
| Gerrit.KeyboardShortcutBehavior, |
| Gerrit.PatchSetBehavior, |
| Gerrit.RESTClientBehavior, |
| ], |
| |
| listeners: { |
| 'topic-changed': '_handleTopicChanged', |
| }, |
| observers: [ |
| '_labelsChanged(_change.labels.*)', |
| '_paramsAndChangeChanged(params, _change)', |
| '_updateSortedRevisions(_change.revisions.*)', |
| ], |
| |
| keyBindings: { |
| 'shift+r': '_handleCapitalRKey', |
| 'a': '_handleAKey', |
| 'd': '_handleDKey', |
| '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.addEventListener('comment-save', this._handleCommentSave.bind(this)); |
| this.addEventListener('comment-refresh', this._getDiffDrafts.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(); |
| } |
| }, |
| |
| _updateSortedRevisions(revisionsRecord) { |
| const revisions = revisionsRecord.base; |
| this._sortedRevisions = this.sortRevisions(Object.values(revisions)); |
| }, |
| |
| _computePrefsButtonHidden(prefs, loggedIn) { |
| return !loggedIn || !prefs; |
| }, |
| |
| _handleEditCommitMessage(e) { |
| this._editingCommitMessage = true; |
| this.$.commitMessageEditor.focusTextarea(); |
| }, |
| |
| _handleCommitMessageSave(e) { |
| const message = e.detail.content; |
| |
| 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; |
| }, |
| |
| _computeHideEditCommitMessage(loggedIn, editing, change) { |
| if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) { |
| return true; |
| } |
| |
| return false; |
| }, |
| |
| _handlePrefsTap(e) { |
| e.preventDefault(); |
| this.$.fileList.openDiffPrefs(); |
| }, |
| |
| _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; |
| }, |
| |
| _handlePatchChange(e) { |
| this._changePatchNum(e.target.value, true); |
| }, |
| |
| _handleReplyTap(e) { |
| e.preventDefault(); |
| this._openReplyDialog(); |
| }, |
| |
| _handleDownloadTap(e) { |
| e.preventDefault(); |
| this.$.downloadOverlay.open().then(() => { |
| this.$.downloadOverlay |
| .setFocusStops(this.$.downloadDialog.getFocusStops()); |
| this.$.downloadDialog.focus(); |
| }); |
| }, |
| |
| _handleDownloadDialogClose(e) { |
| this.$.downloadOverlay.close(); |
| }, |
| |
| _handleMessageReply(e) { |
| const msg = e.detail.message.message; |
| const quoteStr = msg.split('\n').map( |
| line => { return '> ' + line; }).join('\n') + '\n\n'; |
| |
| if (quoteStr !== this.$.replyDialog.quote) { |
| this.$.replyDialog.draft = quoteStr; |
| } |
| this.$.replyDialog.quote = quoteStr; |
| this._openReplyDialog(); |
| }, |
| |
| _handleReplyOverlayOpen(e) { |
| // This is needed so that focus is not set on the reply overlay |
| // when the suggestion overaly from gr-autogrow-textarea opens. |
| if (e.target === this.$.replyOverlay) { |
| this.$.replyDialog.focus(); |
| } |
| }, |
| |
| _handleReplySent(e) { |
| this.$.replyOverlay.close(); |
| this._reload(); |
| }, |
| |
| _handleReplyCancel(e) { |
| this.$.replyOverlay.close(); |
| }, |
| |
| _handleReplyAutogrow(e) { |
| this.$.replyOverlay.refit(); |
| }, |
| |
| _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); |
| }, |
| |
| _paramsChanged(value) { |
| if (value.view !== Gerrit.Nav.View.CHANGE) { |
| this._initialLoadComplete = false; |
| return; |
| } |
| |
| 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', |
| }; |
| |
| if (this._initialLoadComplete && patchChanged) { |
| if (patchRange.patchNum == null) { |
| patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets); |
| } |
| this._patchRange = patchRange; |
| this._reloadPatchNumDependentResources().then(() => { |
| this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, { |
| change: this._change, |
| patchNum: patchRange.patchNum, |
| }); |
| }); |
| return; |
| } |
| |
| this._changeNum = value.changeNum; |
| this._patchRange = patchRange; |
| this.$.relatedChanges.clear(); |
| |
| this._reload().then(() => { |
| this._performPostLoadTasks(); |
| }); |
| }, |
| |
| _performPostLoadTasks() { |
| this.$.relatedChanges.reload(); |
| this._maybeShowReplyDialog(); |
| this._maybeShowRevertDialog(); |
| |
| this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, { |
| change: this._change, |
| patchNum: this._patchRange.patchNum, |
| }); |
| |
| 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.$.messageList.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(); |
| // 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.set('viewState.diffMode', null); |
| 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)); |
| |
| this._updateSelected(); |
| |
| const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')'; |
| this.fire('title-change', {title}); |
| }, |
| |
| /** |
| * Change active patch to the provided patch num. |
| * @param {number|string} patchNum the patchn number to be viewed. |
| * @param {boolean} opt_forceParams When set to true, the resulting URL will |
| * always include the patch range, even if the requested patchNum is |
| * known to be the latest. |
| */ |
| _changePatchNum(patchNum, opt_forceParams) { |
| if (!opt_forceParams) { |
| let currentPatchNum; |
| if (this._change.current_revision) { |
| currentPatchNum = |
| this._change.revisions[this._change.current_revision]._number; |
| } else { |
| currentPatchNum = this.computeLatestPatchNum(this._allPatchSets); |
| } |
| if (this.patchNumEquals(patchNum, currentPatchNum) && |
| this._patchRange.basePatchNum === 'PARENT') { |
| Gerrit.Nav.navigateToChange(this._change); |
| return; |
| } |
| } |
| Gerrit.Nav.navigateToChange(this._change, patchNum, |
| this._patchRange.basePatchNum); |
| }, |
| |
| _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; |
| }, |
| |
| _computePatchInfoClass(patchNum, allPatchSets) { |
| const latestNum = this.computeLatestPatchNum(allPatchSets); |
| if (this.patchNumEquals(patchNum, latestNum)) { |
| return ''; |
| } |
| return 'patchInfo--oldPatchSet'; |
| }, |
| |
| /** |
| * Determines if a patch number should be disabled based on value of the |
| * basePatchNum from gr-file-list. |
| * @param {number} patchNum Patch number available in dropdown |
| * @param {number|string} basePatchNum Base patch number from file list |
| * @return {boolean} |
| */ |
| _computePatchSetDisabled(patchNum, basePatchNum) { |
| if (basePatchNum === 'PARENT') { return false; } |
| |
| return this.findSortedIndex(patchNum, this._sortedRevisions) <= |
| this.findSortedIndex(basePatchNum, this._sortedRevisions); |
| }, |
| |
| _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(); |
| }); |
| }, |
| |
| _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)) { return; } |
| |
| e.preventDefault(); |
| this._determinePageBack(); |
| }, |
| |
| _handleXKey(e) { |
| if (this.shouldSuppressKeyboardShortcut(e) || |
| this.modifierPressed(e)) { return; } |
| |
| e.preventDefault(); |
| this.$.messageList.handleExpandCollapse(true); |
| }, |
| |
| _handleZKey(e) { |
| if (this.shouldSuppressKeyboardShortcut(e) || |
| this.modifierPressed(e)) { return; } |
| |
| e.preventDefault(); |
| this.$.messageList.handleExpandCollapse(false); |
| }, |
| |
| _handleCommaKey(e) { |
| if (this.shouldSuppressKeyboardShortcut(e) || |
| this.modifierPressed(e)) { return; } |
| |
| e.preventDefault(); |
| this.$.fileList.openDiffPrefs(); |
| }, |
| |
| _determinePageBack() { |
| // Default backPage to '/' if user came to change view page |
| // via an email link, etc. |
| Gerrit.Nav.navigateToRelativeUrl(this.backPage || '/'); |
| }, |
| |
| _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.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops()); |
| this.$.replyDialog.open(opt_section); |
| Polymer.dom.flush(); |
| this.$.replyOverlay.center(); |
| }); |
| }, |
| |
| _handleReloadChange(e) { |
| return this._reload().then(() => { |
| // If the change was rebased, we need to reload the page with the |
| // latest patch. |
| if (e.detail.action === 'rebase') { |
| Gerrit.Nav.navigateToChange(this._change); |
| } |
| }); |
| }, |
| |
| _handleGetChangeDetailError(response) { |
| this.fire('page-error', {response}); |
| }, |
| |
| _getDiffDrafts() { |
| return this.$.restAPI.getDiffDrafts(this._changeNum).then(drafts => { |
| this._diffDrafts = drafts; |
| }); |
| }, |
| |
| _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; |
| revisionActions.rebase.enabled = 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; |
| } |
| }, |
| |
| _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; |
| } |
| 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); |
| // TODO: Fetch and process files. |
| } |
| }); |
| }, |
| |
| _getComments() { |
| return this.$.restAPI.getDiffComments(this._changeNum).then(comments => { |
| this._comments = comments; |
| }); |
| }, |
| |
| _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; |
| }); |
| }, |
| |
| _reloadDiffDrafts() { |
| this._diffDrafts = {}; |
| this._getDiffDrafts().then(() => { |
| if (this.$.replyOverlay.opened) { |
| this.async(() => { this.$.replyOverlay.center(); }, 1); |
| } |
| }); |
| }, |
| |
| _reload() { |
| this._loading = true; |
| this._relatedChangesCollapsed = true; |
| |
| this._getLoggedIn().then(loggedIn => { |
| if (!loggedIn) { return; } |
| |
| this._reloadDiffDrafts(); |
| }); |
| |
| const detailCompletes = this._getChangeDetail().then(() => { |
| this._loading = false; |
| this._getProjectConfig(); |
| }); |
| this._getComments(); |
| |
| if (this._patchRange.patchNum) { |
| return Promise.all([ |
| this._reloadPatchNumDependentResources(), |
| detailCompletes, |
| ]).then(() => { |
| return this.$.actions.reload(); |
| }); |
| } else { |
| // The patch number is reliant on the change detail request. |
| return detailCompletes.then(() => { |
| this.$.fileList.reload(); |
| if (!this._latestCommitMessage) { |
| this._getLatestCommitMessage(); |
| } |
| }); |
| } |
| }, |
| |
| /** |
| * Kicks off requests for resources that rely on the patch range |
| * (`this._patchRange`) being defined. |
| */ |
| _reloadPatchNumDependentResources() { |
| return Promise.all([ |
| this._getCommitInfo(), |
| this.$.fileList.reload(), |
| ]); |
| }, |
| |
| _updateSelected() { |
| this._selectedPatchSet = this._patchRange.patchNum; |
| }, |
| |
| _computePatchSetDescription(change, patchNum) { |
| const rev = this.getRevisionByPatchNum(change.revisions, patchNum); |
| return (rev && rev.description) ? |
| rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : ''; |
| }, |
| |
| _computePatchSetCommentsString(allComments, patchNum) { |
| let numComments = 0; |
| let numUnresolved = 0; |
| for (const file in allComments) { |
| if (allComments.hasOwnProperty(file)) { |
| numComments += this.$.fileList.getCommentsForPath( |
| allComments, patchNum, file).length; |
| numUnresolved += this.$.fileList.computeUnresolvedNum( |
| allComments, {}, patchNum, file); |
| } |
| } |
| let commentsStr = ''; |
| if (numComments > 0) { |
| commentsStr = '(' + numComments + ' comments'; |
| if (numUnresolved > 0) { |
| commentsStr += ', ' + numUnresolved + ' unresolved'; |
| } |
| commentsStr += ')'; |
| } |
| return commentsStr; |
| }, |
| |
| _computeDescriptionPlaceholder(readOnly) { |
| return (readOnly ? 'No' : 'Add a') + ' patch set description'; |
| }, |
| |
| _handleDescriptionChanged(e) { |
| const desc = e.detail.trim(); |
| const rev = this.getRevisionByPatchNum(this._change.revisions, |
| this._selectedPatchSet); |
| const sha = this._getPatchsetHash(this._change.revisions, rev); |
| this.$.restAPI.setDescription(this._changeNum, |
| this._selectedPatchSet, desc) |
| .then(res => { |
| if (res.ok) { |
| this.set(['_change', 'revisions', sha, 'description'], desc); |
| } |
| }); |
| }, |
| |
| |
| /** |
| * @param {!Object} revisions The revisions object keyed by revision hashes |
| * @param {?Object} patchSet A revision already fetched from {revisions} |
| * @return {string|undefined} the SHA hash corresponding to the revision. |
| */ |
| _getPatchsetHash(revisions, patchSet) { |
| for (const rev in revisions) { |
| if (revisions.hasOwnProperty(rev) && |
| revisions[rev] === patchSet) { |
| return rev; |
| } |
| } |
| }, |
| |
| _computeCanStartReview(loggedIn, change, account) { |
| return !!(loggedIn && change.work_in_progress && |
| change.owner._account_id === account._account_id); |
| }, |
| |
| _computeDescriptionReadOnly(loggedIn, change, account) { |
| return !(loggedIn && (account._account_id === change.owner._account_id)); |
| }, |
| |
| _computeReplyDisabled() { return false; }, |
| |
| _computeChangePermalinkAriaLabel(changeNum) { |
| return 'Change ' + changeNum; |
| }, |
| |
| _computeCommitClass(collapsed, commitMessage) { |
| if (this._computeCommitToggleHidden(commitMessage)) { return ''; } |
| return collapsed ? 'collapsed' : ''; |
| }, |
| |
| _computeRelatedChangesClass(collapsed, loading) { |
| // TODO(beckysiegel) figure out how to check for customstyle in Polymer2, |
| // since customStyle was removed. |
| if (!loading && !this.customStyle['--relation-chain-max-height']) { |
| this._updateRelatedChangeMaxHeight(); |
| } |
| 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); |
| }, |
| |
| _computeRelatedChangesToggleClass() { |
| // 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 ''; } |
| |
| return this._getScrollHeight(this.$.relatedChanges) > |
| (this._getOffsetHeight(this.$.relatedChanges) + |
| this._getLineHeight(this.$.relatedChanges)) ? '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.fetchIsLatestKnown(this._change, this.$.restAPI) |
| .then(latest => { |
| if (latest) { |
| this._startUpdateCheckTimer(); |
| } else { |
| this._cancelUpdateCheckTimer(); |
| this.fire('show-alert', { |
| message: 'A newer patch set has been uploaded.', |
| // 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(change) { |
| return change.work_in_progress ? 'header wip' : 'header'; |
| }, |
| |
| _computeEditLoaded(patchRangeRecord) { |
| const patchRange = patchRangeRecord.base || {}; |
| return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME); |
| }, |
| }); |
| })(); |