/** * @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 MSG_PREFIX = '#message-'; 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', _legacyUndefinedCheck: true, /** * 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, }, disableDiffPrefs: { type: Boolean, value: false, }, _diffPrefsDisabled: { type: Boolean, computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)', }, _commentThreads: Array, /** @type {?} */ _serverConfig: { type: Object, observer: '_startUpdateCheckTimer', }, _diffPrefs: Object, _numFilesShown: { type: Number, value: DEFAULT_NUM_FILES_SHOWN, observer: '_numFilesShownChanged', }, _account: { type: Object, value: {}, }, _prefs: Object, /** @type {?} */ _changeComments: Object, _canStartReview: { type: Boolean, computed: '_computeCanStartReview(_change)', }, _comments: Object, /** @type {?} */ _change: { type: Object, observer: '_changeChanged', }, _revisionInfo: { type: Object, computed: '_getRevisionInfo(_change)', }, /** @type {?} */ _commitInfo: Object, _currentRevision: { type: Object, computed: '_computeCurrentRevision(_change.current_revision, ' + '_change.revisions)', }, _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, }, _showFileTabContent: { type: Boolean, value: true, }, /** @type {Array} */ _dynamicTabHeaderEndpoints: { type: Array, }, _showPrimaryTabs: { type: Boolean, computed: '_computeShowPrimaryTabs(_dynamicTabHeaderEndpoints)', }, /** @type {Array} */ _dynamicTabContentEndpoints: { type: Array, }, _selectedFilesTabPluginEndpoint: { type: String, }, }, behaviors: [ Gerrit.FireBehavior, 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)', ], keyboardShortcuts() { return { [this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding [this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange', [this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog', [this.Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut', [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode', [this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar', [this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard', [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages', [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages', [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut', [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic', }; }, 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(); }); Gerrit.awaitPluginsLoaded().then(() => { this._dynamicTabHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints('change-view-tab-header'); this._dynamicTabContentEndpoints = Gerrit._endpoints.getDynamicEndpoints('change-view-tab-content'); if (this._dynamicTabContentEndpoints.length !== this._dynamicTabHeaderEndpoints.length) { console.warn('Different number of tab headers and tab content.'); } }); 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._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'); } }); }, _handleToggleDiffMode(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); } }, _handleCommentTabChange() { this._showMessagesView = this.$.commentTabs.selected === 0; }, _handleFileTabChange() { const selectedIndex = this.$$('#primaryTabs').selected; this._showFileTabContent = selectedIndex === 0; // Initial tab is the static files list. this._selectedFilesTabPluginEndpoint = this._dynamicTabContentEndpoints[selectedIndex - 1]; }, _handleShowChecksTable(e) { const idx = this._dynamicTabContentEndpoints.indexOf(e.detail.tab); if (idx === -1) { console.warn(e.detail.tab + ' tab not found'); return; } this.$$('#primaryTabs').selected = idx + 1; this.$$('#primaryTabs').scrollIntoView(); this._handleFileTabChange(); }, _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) { const draft = e.detail.comment; if (!draft.__draft) { 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); 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) { const draft = e.detail.comment; if (!draft.__draft) { return; } 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; const primaryTabs = this.$$('#primaryTabs'); if (primaryTabs) primaryTabs.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; }, _handleMessageAnchorTap(e) { const hash = MSG_PREFIX + e.detail.id; const url = Gerrit.Nav.getUrlForChange(this._change, this._patchRange.patchNum, this._patchRange.basePatchNum, this._editMode, hash); history.replaceState(null, '', url); }, _maybeScrollToMessage(hash) { if (hash.startsWith(MSG_PREFIX)) { this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.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; } const parent = this._getBasePatchNum(change, this._patchRange); this.set('_patchRange.basePatchNum', parent); this.set('_patchRange.patchNum', this._patchRange.patchNum || this.computeLatestPatchNum(this._allPatchSets)); const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')'; this.fire('title-change', {title}); }, /** * Gets base patch number, if it is a parent try and decide from * preference weather to default to `auto merge`, `Parent 1` or `PARENT`. * @param {Object} change * @param {Object} patchRange * @return {number|string} */ _getBasePatchNum(change, patchRange) { if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') { return patchRange.basePatchNum; } const revisionInfo = this._getRevisionInfo(change); if (!revisionInfo) return 'PARENT'; const parentCounts = revisionInfo.getParentCountMap(); // check that there is at least 2 parents otherwise fall back to 1, // which means there is only one parent. const parentCount = parentCounts.hasOwnProperty(1) ? parentCounts[1] : 1; const preferFirst = this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT'; if (parentCount > 1 && preferFirst && !patchRange.patchNum) { return -1; } return 'PARENT'; }, _computeShowPrimaryTabs(dynamicTabContentEndpoints) { return dynamicTabContentEndpoints.length > 0; }, _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 . @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; }, _handleOpenReplyDialog(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); }); }, _handleOpenDownloadDialogShortcut(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this.$.downloadOverlay.open(); }, _handleEditTopic(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this.$.metadata.editTopic(); }, _handleRefreshChange(e) { if (this.shouldSuppressKeyboardShortcut(e)) { return; } e.preventDefault(); Gerrit.Nav.navigateToChange(this._change); }, _handleToggleChangeStar(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this.$.changeStar.toggleStar(); }, _handleUpToDashboard(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this._determinePageBack(); }, _handleExpandAllMessages(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this.messagesList.handleExpandCollapse(true); }, _handleCollapseAllMessages(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } e.preventDefault(); this.messagesList.handleExpandCollapse(false); }, _handleOpenDiffPrefsShortcut(e) { if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) { return; } if (this._diffPrefsDisabled) { 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; }); }, _getPreferences() { return this.$.restAPI.getPreferences(); }, _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(); const prefCompletes = this._getPreferences(); return Promise.all([detailCompletes, editCompletes, prefCompletes]) .then(([change, edit, prefs]) => { this._prefs = prefs; 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; }) .then(() => { this.$.reporting.changeDisplayed(); }); // 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; }, /** * 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); }, _getRevisionInfo(change) { return new Gerrit.RevisionInfo(change); }, _computeCurrentRevision(currentRevision, revisions) { return revisions && revisions[currentRevision]; }, _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) { return disableDiffPrefs || !loggedIn; }, }); })();