Merge changes from topic "gr-change-view-to-ts"
* changes:
Convert gr-change-view to typescript
Rename files to preserve history
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8e8eaf3..807adef 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -190,6 +190,17 @@
INHERIT = 'INHERIT',
}
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export enum MergeStrategy {
+ RECURSIVE = 'recursive',
+ RESOLVE = 'resolve',
+ SIMPLE_TWO_WAY_IN_CORE = 'simple-two-way-in-core',
+ OURS = 'ours',
+ THEIRS = 'theirs',
+}
+
/*
* Enum for possible configured value in InheritedBooleanInfo.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
deleted file mode 100644
index 20fe1a6..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ /dev/null
@@ -1,2353 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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.
- */
-import '@polymer/paper-tabs/paper-tabs.js';
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-change-star/gr-change-star.js';
-import '../../shared/gr-change-status/gr-change-status.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-content/gr-editable-content.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-change-actions/gr-change-actions.js';
-import '../gr-change-metadata/gr-change-metadata.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-download-dialog/gr-download-dialog.js';
-import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-included-in-dialog/gr-included-in-dialog.js';
-import '../gr-messages-list/gr-messages-list.js';
-import '../gr-related-changes-list/gr-related-changes-list.js';
-import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-reply-dialog/gr-reply-dialog.js';
-import '../gr-thread-list/gr-thread-list.js';
-import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {PrimaryTab, SecondaryTab} from '../../../constants/constants.js';
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
- fetchChangeUpdates,
- hasEditBasedOnCurrentPatchSet,
- hasEditPatchsetLoaded,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
-import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list.js';
-
-const CHANGE_ID_ERROR = {
- MISMATCH: 'mismatch',
- MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN =
- /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-
-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 CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-// Making the tab names more unique in case a plugin adds one with same name
-const ROBOT_COMMENTS_LIMIT = 10;
-
-// types used in this file
-/**
- * Type for the custom event to switch tab.
- *
- * @typedef {Object} SwitchTabEventDetail
- * @property {?string} tab - name of the tab to set as active, from custom event
- * @property {?boolean} scrollIntoView - scroll into the tab afterwards, from custom event
- * @property {?number} value - index of tab to set as active, from paper-tabs event
- */
-
-/**
- * @extends PolymerElement
- */
-class GrChangeView extends KeyboardShortcutMixin(
- GestureEventListeners(LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return '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
- */
-
- static get properties() {
- return {
- /**
- * 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,
- // TODO(taoalpha): Consider replacing diffDrafts
- // with _draftCommentThreads everywhere, currently only
- // replaced in reply-dialoig
- _draftCommentThreads: {
- type: Array,
- },
- _robotCommentThreads: {
- type: Array,
- computed: '_computeRobotCommentThreads(_commentThreads,'
- + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
- },
- /** @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)',
- },
- /** @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)',
- observer: '_handleCurrentRevisionUpdate',
- },
- _files: Object,
- _changeNum: String,
- _diffDrafts: {
- type: Object,
- value() { return {}; },
- },
- _editingCommitMessage: {
- type: Boolean,
- value: false,
- },
- _hideEditCommitMessage: {
- type: Boolean,
- computed: '_computeHideEditCommitMessage(_loggedIn, ' +
- '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
- '_commitCollapsible)',
- },
- _diffAgainst: String,
- /** @type {?string} */
- _latestCommitMessage: {
- type: String,
- value: '',
- },
- _constants: {
- type: Object,
- value: {
- SecondaryTab,
- PrimaryTab,
- },
- },
- _messages: {
- type: Object,
- value: {
- NO_ROBOT_COMMENTS_THREADS_MSG,
- },
- },
- _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,
- _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, _submitEnabled)',
- },
- /** If false, then the "Show more" button was used to expand. */
- _commitCollapsed: {
- type: Boolean,
- value: true,
- },
- /** Is the "Show more/less" button visible? */
- _commitCollapsible: {
- type: Boolean,
- computed: '_computeCommitCollapsible(_latestCommitMessage)',
- },
- _relatedChangesCollapsed: {
- type: Boolean,
- value: true,
- },
- /** @type {?number} */
- _updateCheckTimerHandle: Number,
- _editMode: {
- type: Boolean,
- computed: '_computeEditMode(_patchRange.*, params.*)',
- },
- _showRelatedToggle: {
- type: Boolean,
- value: false,
- observer: '_updateToggleContainerClass',
- },
- _parentIsCurrent: {
- type: Boolean,
- computed: '_isParentCurrent(_currentRevisionActions)',
- },
- _submitEnabled: {
- type: Boolean,
- computed: '_isSubmitEnabled(_currentRevisionActions)',
- },
-
- /** @type {?} */
- _mergeable: {
- type: Boolean,
- value: undefined,
- },
- _showFileTabContent: {
- type: Boolean,
- value: true,
- },
- /** @type {Array<string>} */
- _dynamicTabHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicTabContentEndpoints: {
- type: Array,
- },
- // The dynamic content of the plugin added tab
- _selectedTabPluginEndpoint: {
- type: String,
- },
- // The dynamic heading of the plugin added tab
- _selectedTabPluginHeader: {
- type: String,
- },
- _robotCommentsPatchSetDropdownItems: {
- type: Array,
- value() { return []; },
- computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
- '_commentThreads)',
- },
- _currentRobotCommentsPatchSet: {
- type: Number,
- },
-
- /**
- * @type {Array<string>} this is a two-element tuple to always
- * hold the current active tab for both primary and secondary tabs
- */
- _activeTabs: {
- type: Array,
- value: [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG],
- },
- _showAllRobotComments: {
- type: Boolean,
- value: false,
- },
- _showRobotCommentsButton: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_labelsChanged(_change.labels.*)',
- '_paramsAndChangeChanged(params, _change)',
- '_patchNumChanged(_patchRange.patchNum)',
- ];
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
- [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
- [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
- [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
- [Shortcut.OPEN_DOWNLOAD_DIALOG]:
- '_handleOpenDownloadDialogShortcut',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
- [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
- [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
- [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
- [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
- [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
- '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]:
- '_handleDiffBaseAgainstLatest',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- connectedCallback() {
- super.connectedCallback();
- this._throttledToggleChangeStar = this._throttleWrap(e =>
- this._handleToggleChangeStar(e));
- }
-
- /** @override */
- created() {
- super.created();
-
- this.addEventListener('topic-changed',
- () => this._handleTopicChanged());
-
- this.addEventListener(
- // 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',
- () => this._handleHideBackgroundContent());
-
- this.addEventListener('fullscreen-overlay-closed',
- () => this._handleShowBackgroundContent());
-
- this.addEventListener('diff-comments-modified',
- () => this._handleReloadCommentThreads());
-
- this.addEventListener('open-reply-dialog',
- e => this._openReplyDialog());
- }
-
- /** @override */
- attached() {
- super.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();
- });
-
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._dynamicTabHeaderEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
- this._dynamicTabContentEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
- if (this._dynamicTabContentEndpoints.length !==
- this._dynamicTabHeaderEndpoints.length) {
- console.warn('Different number of tab headers and tab content.');
- }
- })
- .then(() => this._initActiveTabs(this.params));
-
- this.addEventListener('comment-save', e => this._handleCommentSave(e));
- this.addEventListener('comment-refresh', e => this._reloadDrafts(e));
- this.addEventListener('comment-discard',
- e => this._handleCommentDiscard(e));
- this.addEventListener('change-message-deleted',
- () => this._reload());
- this.addEventListener('editable-content-save',
- e => this._handleCommitMessageSave(e));
- this.addEventListener('editable-content-cancel',
- e => this._handleCommitMessageCancel(e));
- this.addEventListener('open-fix-preview',
- e => this._onOpenFixPreview(e));
- this.addEventListener('close-fix-preview',
- e => this._onCloseFixPreview(e));
- this.listen(window, 'scroll', '_handleScroll');
- this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-
- this.addEventListener('show-primary-tab',
- e => this._setActivePrimaryTab(e));
- this.addEventListener('show-secondary-tab',
- e => this._setActiveSecondaryTab(e));
- this.addEventListener('reload', e => {
- e.stopPropagation();
- this._reload(/* opt_isLocationChange= */false,
- /* opt_clearPatchset= */e.detail && e.detail.clearPatchset);
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'scroll', '_handleScroll');
- this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
- if (this._updateCheckTimerHandle) {
- this._cancelUpdateCheckTimer();
- }
- }
-
- get messagesList() {
- return this.shadowRoot.querySelector('gr-messages-list');
- }
-
- get threadList() {
- return this.shadowRoot.querySelector('gr-thread-list');
- }
-
- _changeStatusString(change) {
- return changeStatusString(change);
- }
-
- /**
- * @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');
- }
- });
- }
-
- _onOpenFixPreview(e) {
- this.$.applyFixDialog.open(e);
- }
-
- _onCloseFixPreview(e) {
- this._reload();
- }
-
- _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);
- }
- }
-
- _isTabActive(tab, activeTabs) {
- return activeTabs.includes(tab);
- }
-
- /**
- * Actual implementation of switching a tab
- *
- * @param {!HTMLElement} paperTabs - the parent tabs container
- * @param {!SwitchTabEventDetail} activeDetails
- */
- _setActiveTab(paperTabs, activeDetails) {
- const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
- const tabs = paperTabs.querySelectorAll('paper-tab');
- let activeIndex = -1;
- if (activeTabIndex !== undefined) {
- activeIndex = activeTabIndex;
- } else {
- for (let i = 0; i <= tabs.length; i++) {
- const tab = tabs[i];
- if (tab.dataset['name'] === activeTabName) {
- activeIndex = i;
- break;
- }
- }
- }
- if (activeIndex === -1) {
- console.warn('tab not found with given info', activeDetails);
- return;
- }
- const tabName = tabs[activeIndex].dataset['name'];
- if (scrollIntoView) {
- paperTabs.scrollIntoView();
- }
- if (paperTabs.selected !== activeIndex) {
- paperTabs.selected = activeIndex;
- this.reporting.reportInteraction('show-tab', {tabName});
- }
- return tabName;
- }
-
- /**
- * Changes active primary tab.
- *
- * @param {CustomEvent<SwitchTabEventDetail>} e
- */
- _setActivePrimaryTab(e) {
- const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
- const activeTabName = this._setActiveTab(primaryTabs, {
- activeTabName: e.detail.tab,
- activeTabIndex: e.detail.value,
- scrollIntoView: e.detail.scrollIntoView,
- });
- if (activeTabName) {
- this._activeTabs = [activeTabName, this._activeTabs[1]];
-
- // update plugin endpoint if its a plugin tab
- const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
- activeTabName);
- if (pluginIndex !== -1) {
- this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
- pluginIndex];
- this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
- pluginIndex];
- } else {
- this._selectedTabPluginEndpoint = '';
- this._selectedTabPluginHeader = '';
- }
- }
- }
-
- /**
- * Changes active secondary tab.
- *
- * @param {CustomEvent<SwitchTabEventDetail>} e
- */
- _setActiveSecondaryTab(e) {
- const secondaryTabs = this.shadowRoot.querySelector('#secondaryTabs');
- const activeTabName = this._setActiveTab(secondaryTabs, {
- activeTabName: e.detail.tab,
- activeTabIndex: e.detail.value,
- scrollIntoView: e.detail.scrollIntoView,
- });
- if (activeTabName) {
- this._activeTabs = [this._activeTabs[0], activeTabName];
- }
- }
-
- _handleEditCommitMessage() {
- 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, submitEnabled) {
- // Polymer 2: check for undefined
- if ([
- change,
- mergeable,
- ].includes(undefined)) {
- // To keep consistent with Polymer 1, we are returning undefined
- // if not all dependencies are defined
- return undefined;
- }
-
- // Show no chips until mergeability is loaded.
- if (mergeable === null) {
- return [];
- }
-
- const options = {
- includeDerived: true,
- mergeable: !!mergeable,
- submitEnabled: !!submitEnabled,
- };
- return changeStatuses(change, options);
- }
-
- _computeHideEditCommitMessage(
- loggedIn, editing, change, editMode, collapsed, collapsible) {
- if (!loggedIn || editing ||
- (change && change.status === ChangeStatus.MERGED) ||
- editMode ||
- (collapsed && collapsible)) {
- return true;
- }
-
- return false;
- }
-
- _robotCommentCountPerPatchSet(threads) {
- return threads.reduce((robotCommentCountMap, thread) => {
- const comments = thread.comments;
- const robotCommentsCount = comments.reduce((acc, comment) =>
- (comment.robot_id ? acc + 1 : acc), 0);
- robotCommentCountMap[comments[0].patch_set] =
- (robotCommentCountMap[comments[0].patch_set] || 0) +
- robotCommentsCount;
- return robotCommentCountMap;
- }, {});
- }
-
- _computeText(patch, commentThreads) {
- const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
- const commentCnt = commentCount[patch._number] || 0;
- if (commentCnt === 0) return `Patchset ${patch._number}`;
- const findingsText = commentCnt === 1 ? 'finding' : 'findings';
- return `Patchset ${patch._number}`
- + ` (${commentCnt} ${findingsText})`;
- }
-
- _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
- if (!change || !commentThreads || !change.revisions) return [];
-
- return Object.values(change.revisions)
- .filter(patch => patch._number !== 'edit')
- .map(patch => {
- return {
- text: this._computeText(patch, commentThreads),
- value: patch._number,
- };
- })
- .sort((a, b) => b.value - a.value);
- }
-
- _handleCurrentRevisionUpdate(currentRevision) {
- this._currentRobotCommentsPatchSet = currentRevision._number;
- }
-
- _handleRobotCommentPatchSetChanged(e) {
- const patchSet = parseInt(e.detail.value);
- if (patchSet === this._currentRobotCommentsPatchSet) return;
- this._currentRobotCommentsPatchSet = patchSet;
- }
-
- _computeShowText(showAllRobotComments) {
- return showAllRobotComments ? 'Show Less' : 'Show more';
- }
-
- _toggleShowRobotComments() {
- this._showAllRobotComments = !this._showAllRobotComments;
- }
-
- _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
- showAllRobotComments) {
- if (!commentThreads || !currentRobotCommentsPatchSet) return [];
- const threads = commentThreads.filter(thread => {
- const comments = thread.comments || [];
- return comments.length && comments[0].robot_id && (comments[0].patch_set
- === currentRobotCommentsPatchSet);
- });
- this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
- return threads.slice(0, showAllRobotComments ? undefined :
- ROBOT_COMMENTS_LIMIT);
- }
-
- _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();
- 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);
- flush();
- });
- }
-
- _computeTotalCommentCounts(unresolvedCount, changeComments) {
- if (!changeComments) return undefined;
- 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 = {...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.
- (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 = {...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(() => {
- 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 => '> ' + 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.addEventListener('change-details-loaded',
- () => {
- this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
- }, {once: true});
- this.$.replyOverlay.close();
- this._reload();
- }
-
- _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(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this.$.fileList.expandAllDiffs();
- }
-
- _collapseAllDiffs() {
- this.$.fileList.collapseAllDiffs();
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.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);
- const changeChanged = this._changeNum !== value.changeNum;
-
- const patchRange = {
- patchNum: value.patchNum,
- basePatchNum: value.basePatchNum || 'PARENT',
- };
- // TODO(TS): remove once proper type for patchRange is defined
- if (!isNaN(Number(patchRange.patchNum))) {
- patchRange.patchNum = Number(patchRange.patchNum);
- }
- if (!isNaN(Number(patchRange.basePatchNum))) {
- patchRange.basePatchNum = Number(patchRange.basePatchNum);
- }
-
- 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 (!changeChanged && patchChanged) {
- if (patchRange.patchNum == null) {
- patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
- }
- this._reloadPatchNumDependentResources().then(() => {
- this._sendShowChangeEvent();
- });
- return;
- }
-
- this._initialLoadComplete = false;
- this._changeNum = value.changeNum;
- this.$.relatedChanges.clear();
-
- this._reload(true).then(() => {
- this._performPostLoadTasks();
- });
-
- getPluginLoader().awaitPluginsLoaded()
- .then(() => {
- this._initActiveTabs(value);
- });
- }
-
- _initActiveTabs(params = {}) {
- let primaryTab = PrimaryTab.FILES;
- if (params.queryMap && params.queryMap.has('tab')) {
- primaryTab = params.queryMap.get('tab');
- }
- this._setActivePrimaryTab({
- detail: {
- tab: primaryTab,
- },
- });
- this._setActiveSecondaryTab({
- detail: {
- tab: SecondaryTab.CHANGE_LOG,
- },
- });
- }
-
- _sendShowChangeEvent() {
- this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
- change: this._change,
- patchNum: this._patchRange.patchNum,
- info: {mergeable: this._mergeable},
- });
- }
-
- _performPostLoadTasks() {
- this._maybeShowReplyDialog();
- this._maybeShowRevertDialog();
- this._maybeShowDownloadDialog();
-
- this._sendShowChangeEvent();
-
- 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, change) {
- // Polymer 2: check for undefined
- if ([value, change].includes(undefined)) {
- return;
- }
-
- // 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 ||
- 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 = GerritNav.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() {
- getPluginLoader().awaitPluginsLoaded()
- .then(() => this._getLoggedIn())
- .then(loggedIn => {
- if (!loggedIn || !this._change ||
- this._change.status !== 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);
- }
- });
- }
-
- _maybeShowDownloadDialog() {
- if (this.viewState.showDownloadDialog) {
- this._handleOpenDownloadDialog();
- this.set('viewState.showDownloadDialog', 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; }
-
- // We get the parent first so we keep the original value for basePatchNum
- // and not the updated value.
- const parent = this._getBasePatchNum(change, this._patchRange);
-
- this.set('_patchRange.patchNum', this._patchRange.patchNum ||
- computeLatestPatchNum(this._allPatchSets));
-
- this.set('_patchRange.basePatchNum', parent);
-
- const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title},
- composed: true, bubbles: true,
- }));
- }
-
- /**
- * Gets base patch number, if it is a parent try and decide from
- * preference whether 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';
- }
-
- _computeChangeUrl(change) {
- return GerritNav.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) {
- // Polymer 2: check for undefined
- if ([commitMessage, change].includes(undefined)) {
- return undefined;
- }
-
- 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[2];
- }
-
- 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) {
- // Polymer 2: check for undefined
- if ([changeRecord, canStartReview].includes(undefined)) {
- return 'Reply';
- }
-
- const drafts = (changeRecord && changeRecord.base) || {};
- const draftCount = Object.keys(drafts)
- .reduce((count, file) => count + drafts[file].length, 0);
-
- let label = canStartReview ? 'Start Review' : '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.dispatchEvent(new CustomEvent('show-auth-required', {
- composed: true, bubbles: true,
- }));
- return;
- }
-
- e.preventDefault();
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- });
- }
-
- _handleOpenDownloadDialogShortcut(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._handleOpenDownloadDialog();
- }
-
- _handleEditTopic(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.metadata.editTopic();
- }
-
- _handleDiffAgainstBase(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Base is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLeft(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Left is already base.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
- }
-
- _handleDiffAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Latest is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffRightAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Right is already latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum,
- this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
- patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Already diffing base against latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum);
- }
-
- _handleRefreshChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- e.preventDefault();
- this._reload(/* opt_isLocationChange= */false,
- /* opt_clearPatchset= */true);
- }
-
- _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.
- GerritNav.navigateToRelativeUrl(this.backPage ||
- GerritNav.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(EventType.LABEL_CHANGE, {
- change: this._change,
- });
- }
-
- /**
- * @param {string=} opt_section
- */
- _openReplyDialog(opt_section) {
- this.$.replyOverlay.open().finally(() => {
- // the following code should be executed no matter open succeed or not
- this._resetReplyOverlayFocusStops();
- this.$.replyDialog.open(opt_section);
- flush();
- this.$.replyOverlay.center();
- });
- }
-
- _handleGetChangeDetailError(response) {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getServerConfig() {
- return this.$.restAPI.getConfig();
- }
-
- _getProjectConfig() {
- if (!this._change) return;
- return this.$.restAPI.getProjectConfig(this._change.project).then(
- config => {
- this._projectConfig = config;
- });
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _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: SPECIAL_PATCH_SET_NUM.EDIT,
- 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.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
- // 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, r => this._handleGetChangeDetailError(r));
- 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;
- }
-
- 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 ||
- 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._selectedRevision = currentRevision;
- // TODO: Fetch and process files.
- } else {
- this._selectedRevision =
- Object.values(this._change.revisions).find(
- revision => {
- // edit patchset is a special one
- const thePatchNum = this._patchRange.patchNum;
- if (thePatchNum === 'edit') {
- return revision._number === thePatchNum;
- }
- return revision._number === parseInt(thePatchNum, 10);
- });
- }
- });
- }
-
- _isSubmitEnabled(revisionActions) {
- return !!(revisionActions && revisionActions.submit &&
- revisionActions.submit.enabled);
- }
-
- _isParentCurrent(revisionActions) {
- if (revisionActions && revisionActions.rebase) {
- return !revisionActions.rebase.enabled;
- } else {
- return true;
- }
- }
-
- _getEdit() {
- return this.$.restAPI.getChangeEdit(this._changeNum, true);
- }
-
- _getLatestCommitMessage() {
- return this.$.restAPI.getChangeCommitInfo(this._changeNum,
- computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
- if (!commitInfo) return Promise.resolve();
- 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(() => e.detail.resolve());
- }
-
- /**
- * Fetches a new changeComment object, and data for all types of comments
- * (comments, robot comments, draft comments) is requested.
- */
- _reloadComments() {
- // We are resetting all comment related properties, because we want to avoid
- // a new change being loaded and then paired with outdated comments.
- this._changeComments = undefined;
- this._commentThreads = undefined;
- this._diffDrafts = undefined;
- this._draftCommentThreads = undefined;
- this._robotCommentThreads = undefined;
- return this.$.commentAPI.loadAll(this._changeNum)
- .then(comments => this._recomputeComments(comments));
- }
-
- /**
- * Fetches a new changeComment object, but only updated data for drafts is
- * requested.
- *
- * TODO(taoalpha): clean up this and _reloadComments, as single comment
- * can be a thread so it does not make sense to only update drafts
- * without updating threads
- */
- _reloadDrafts() {
- return this.$.commentAPI.reloadDrafts(this._changeNum)
- .then(comments => this._recomputeComments(comments));
- }
-
- _recomputeComments(comments) {
- this._changeComments = comments;
- this._diffDrafts = {...this._changeComments.drafts};
- this._commentThreads = this._changeComments.getAllThreadsForChange();
- this._draftCommentThreads = this._commentThreads
- .filter(thread => thread.comments[thread.comments.length - 1].__draft)
- .map(thread => {
- const copiedThread = {...thread};
- // Make a hardcopy of all comments and collapse all but last one
- const commentsInThread = copiedThread.comments = thread.comments
- .map(comment => { return {...comment, collapsed: true}; });
- commentsInThread[commentsInThread.length - 1].collapsed = false;
- return copiedThread;
- });
- }
-
- /**
- * Reload the change.
- *
- * @param {boolean=} opt_isLocationChange Reloads the related changes
- * when true and ends reporting events that started on location change.
- * @param {boolean=} opt_clearPatchset Reloads the related changes
- * ignoring any patchset choice made.
- * @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_isLocationChange, opt_clearPatchset) {
- if (opt_clearPatchset) {
- GerritNav.navigateToChange(this._change);
- return;
- }
- this._loading = true;
- this._relatedChangesCollapsed = true;
- this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
- this.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
- // 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;
- this.dispatchEvent(new CustomEvent('change-details-loaded',
- {bubbles: true, composed: true}));
- })
- .then(() => {
- this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
- if (opt_isLocationChange) {
- 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 && 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_isLocationChange) {
- this._editingCommitMessage = false;
- const relatedChangesLoaded = coreDataPromise
- .then(() => this.$.relatedChanges.reload());
- allDataPromises.push(relatedChangesLoaded);
- }
-
- Promise.all(allDataPromises).then(() => {
- this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
- if (opt_isLocationChange) {
- 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 (!this._change) {
- this._mergeable = null;
- return Promise.resolve();
- }
- // 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 === ChangeStatus.MERGED ||
- this._change.status === 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;
- }
-
- _computeCommitMessageCollapsed(collapsed, collapsible) {
- return collapsible && collapsed;
- }
-
- _computeRelatedChangesClass(collapsed) {
- return collapsed ? 'collapsed' : '';
- }
-
- _computeCollapseText(collapsed) {
- // Symbols are up and down triangles.
- return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
- }
-
- /**
- * Returns the text to be copied when
- * click the copy icon next to change subject
- *
- * @param {!Object} change
- */
- _computeCopyTextForTitle(change) {
- return `${change._number}: ${change.subject} | ` +
- `${location.protocol}//${location.host}` +
- `${this._computeChangeUrl(change)}`;
- }
-
- _toggleCommitCollapsed() {
- this._commitCollapsed = !this._commitCollapsed;
- if (this._commitCollapsed) {
- window.scrollTo(0, 0);
- }
- }
-
- _toggleRelatedChangesCollapsed() {
- this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
- if (this._relatedChangesCollapsed) {
- window.scrollTo(0, 0);
- }
- }
-
- _computeCommitCollapsible(commitMessage) {
- if (!commitMessage) { return false; }
- 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;
-
- 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 (this._commitCollapsible) {
- // 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 (this._commitCollapsible) {
- 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.
- if (!getComputedStyleValue('--relation-chain-max-height', this)) {
- 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(() => {
- const change = this._change;
- fetchChangeUpdates(change, this.$.restAPI).then(result => {
- let toastMessage = null;
- if (!result.isLatest) {
- toastMessage = ReloadToastMessage.NEWER_REVISION;
- } else if (result.newStatus === ChangeStatus.MERGED) {
- toastMessage = ReloadToastMessage.MERGED;
- } else if (result.newStatus === ChangeStatus.ABANDONED) {
- toastMessage = ReloadToastMessage.ABANDONED;
- } else if (result.newStatus === ChangeStatus.NEW) {
- toastMessage = ReloadToastMessage.RESTORED;
- } else if (result.newMessages) {
- toastMessage = ReloadToastMessage.NEW_MESSAGE;
- }
-
- // We have to make sure that the update is still relevant for the user.
- // Since starting to fetch the change update the user may have sent a
- // reply, or the change might have been reloaded, or it could be in the
- // process of being reloaded.
- const changeWasReloaded = change !== this._change;
- if (!toastMessage || this._loading || changeWasReloaded) {
- this._startUpdateCheckTimer();
- return;
- }
-
- this._cancelUpdateCheckTimer();
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: toastMessage,
- // Persist this alert.
- dismissOnNavigation: true,
- action: 'Reload',
- callback: () => {
- this._reload(/* opt_isLocationChange= */false,
- /* opt_clearPatchset= */true);
- },
- },
- composed: true, bubbles: true,
- }));
- });
- }, 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 ([patchRangeRecord, paramsRecord].includes(undefined)) {
- return undefined;
- }
-
- if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
- const patchRange = patchRangeRecord.base || {};
- return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
- }
-
- _handleFileActionTap(e) {
- e.preventDefault();
- const controls = this.$.fileListHeader
- .shadowRoot.querySelector('#editControls');
- const path = e.detail.path;
- switch (e.detail.action) {
- case GrEditConstants.Actions.DELETE.id:
- controls.openDeleteDialog(path);
- break;
- case GrEditConstants.Actions.OPEN.id:
- GerritNav.navigateToRelativeUrl(
- GerritNav.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;
- }
-
- let patchNum = parseInt(patchNumStr, 10);
- if (patchNumStr === 'edit') {
- patchNum = patchNumStr;
- }
-
- 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 === SPECIAL_PATCH_SET_NUM.EDIT);
-
- if (editInfo) {
- GerritNav.navigateToChange(this._change, SPECIAL_PATCH_SET_NUM.EDIT);
- return;
- }
-
- // Avoid putting patch set in the URL unless a non-latest patch set is
- // selected.
- let patchNum;
- if (!patchNumEquals(this._patchRange.patchNum,
- computeLatestPatchNum(this._allPatchSets))) {
- patchNum = this._patchRange.patchNum;
- }
- GerritNav.navigateToChange(this._change, patchNum, null, true);
- }
-
- _handleStopEditTap() {
- GerritNav.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 RevisionInfo(change);
- }
-
- _computeCurrentRevision(currentRevision, revisions) {
- return currentRevision && revisions && revisions[currentRevision];
- }
-
- _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
- return disableDiffPrefs || !loggedIn;
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeLatestPatchNum(allPatchSets) {
- return computeLatestPatchNum(allPatchSets);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _hasEditBasedOnCurrentPatchSet(allPatchSets) {
- return hasEditBasedOnCurrentPatchSet(allPatchSets);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _hasEditPatchsetLoaded(patchRangeRecord) {
- return hasEditPatchsetLoaded(patchRangeRecord);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeAllPatchSets(change) {
- return computeAllPatchSets(change);
- }
-}
-
-customElements.define(GrChangeView.is, GrChangeView);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
new file mode 100644
index 0000000..68fb622
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -0,0 +1,2788 @@
+/**
+ * @license
+ * Copyright (C) 2015 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.
+ */
+import '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/shared-styles';
+import '../../diff/gr-comment-api/gr-comment-api';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-content/gr-editable-content';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-change-actions/gr-change-actions';
+import '../gr-change-metadata/gr-change-metadata';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-download-dialog/gr-download-dialog';
+import '../gr-file-list-header/gr-file-list-header';
+import '../gr-included-in-dialog/gr-included-in-dialog';
+import '../gr-messages-list/gr-messages-list';
+import '../gr-related-changes-list/gr-related-changes-list';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-reply-dialog/gr-reply-dialog';
+import '../gr-thread-list/gr-thread-list';
+import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-view_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ CustomKeyboardEvent,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
+import {appContext} from '../../../services/app-context';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ fetchChangeUpdates,
+ hasEditBasedOnCurrentPatchSet,
+ hasEditPatchsetLoaded,
+ patchNumEquals,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
+import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
+import {
+ AccountDetailInfo,
+ ChangeInfo,
+ NumericChangeId,
+ PatchRange,
+ ActionNameToActionInfoMap,
+ CommitId,
+ PatchSetNum,
+ ParentPatchSetNum,
+ EditPatchSetNum,
+ ServerInfo,
+ ConfigInfo,
+ PreferencesInfo,
+ CommitInfo,
+ DiffPreferencesInfo,
+ RevisionInfo,
+ EditInfo,
+ LabelNameToInfoMap,
+ UrlEncodedCommentId,
+ QuickLabelInfo,
+ ApprovalInfo,
+ ElementPropertyDeepChange,
+} from '../../../types/common';
+import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
+import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
+import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
+import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {
+ GrCommentApi,
+ ChangeComments,
+} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {
+ CommentThread,
+ UIDraft,
+ DraftInfo,
+ isDraftThread,
+ isRobot,
+} from '../../../utils/comment-util';
+import {
+ PolymerDeepPropertyChange,
+ PolymerSpliceChange,
+ PolymerSplice,
+} from '@polymer/polymer/interfaces';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ GrFileList,
+ DEFAULT_NUM_FILES_SHOWN,
+} from '../gr-file-list/gr-file-list';
+import {isPolymerSpliceChange} from '../../../types/types';
+
+const CHANGE_ID_ERROR = {
+ MISMATCH: 'mismatch',
+ MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+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',
+};
+
+enum DiffViewMode {
+ SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+ UNIFIED = 'UNIFIED_DIFF',
+}
+
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const ROBOT_COMMENTS_LIMIT = 10;
+
+// Type for the custom event to switch tab.
+interface SwitchTabEventDetail {
+ // name of the tab to set as active, from custom event
+ tab?: string;
+ // index of tab to set as active, from paper-tabs event
+ value?: number;
+ // scroll into the tab afterwards, from custom event
+ scrollIntoView?: boolean;
+}
+
+export interface ChangeViewState {
+ diffMode?: DiffViewMode;
+ scrollTop?: number;
+ showDownloadDialog?: boolean;
+ showReplyDialog?: boolean;
+ changeNum?: NumericChangeId;
+ numFilesShown?: number;
+ patchRange?: PatchRange;
+ diffViewMode?: boolean;
+}
+
+export interface GrChangeView {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: GrJsApiInterface;
+ commentAPI: GrCommentApi;
+ applyFixDialog: GrApplyFixDialog;
+ fileList: GrFileList & Element;
+ fileListHeader: GrFileListHeader;
+ commitMessageEditor: GrEditableContent;
+ includedInOverlay: GrOverlay;
+ includedInDialog: GrIncludedInDialog;
+ downloadOverlay: GrOverlay;
+ downloadDialog: GrDownloadDialog;
+ uploadHelpOverlay: GrOverlay;
+ replyOverlay: GrOverlay;
+ replyDialog: GrReplyDialog;
+ mainContent: HTMLDivElement;
+ relatedChanges: GrRelatedChangesList;
+ changeStar: GrChangeStar;
+ actions: GrChangeActions;
+ commitMessage: HTMLDivElement;
+ commitAndRelated: HTMLDivElement;
+ metadata: GrChangeMetadata;
+ relatedChangesToggle: HTMLDivElement;
+ mainChangeInfo: HTMLDivElement;
+ };
+}
+@customElement('gr-change-view')
+export class GrChangeView extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * 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
+ */
+
+ reporting = appContext.reportingService;
+
+ /**
+ * URL params passed from the router.
+ */
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementChangeViewParams;
+
+ @property({type: Object, notify: true, observer: '_viewStateChanged'})
+ viewState: ChangeViewState = {};
+
+ @property({type: String})
+ backPage?: string;
+
+ @property({type: Boolean})
+ hasParent?: boolean;
+
+ @property({type: Object})
+ keyEventTarget = document.body;
+
+ @property({type: Boolean})
+ disableEdit = false;
+
+ @property({type: Boolean})
+ disableDiffPrefs = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+ })
+ _diffPrefsDisabled?: boolean;
+
+ @property({type: Array})
+ _commentThreads?: CommentThread[];
+
+ // TODO(taoalpha): Consider replacing diffDrafts
+ // with _draftCommentThreads everywhere, currently only
+ // replaced in reply-dialog
+ @property({type: Array})
+ _draftCommentThreads?: CommentThread[];
+
+ @property({
+ type: Array,
+ computed:
+ '_computeRobotCommentThreads(_commentThreads,' +
+ ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+ })
+ _robotCommentThreads?: CommentThread[];
+
+ @property({type: Object, observer: '_startUpdateCheckTimer'})
+ _serverConfig?: ServerInfo;
+
+ @property({type: Object})
+ _diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Number, observer: '_numFilesShownChanged'})
+ _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Object})
+ _account?: AccountDetailInfo;
+
+ @property({type: Object})
+ _prefs?: PreferencesInfo;
+
+ @property({type: Object})
+ _changeComments?: ChangeComments;
+
+ @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
+ _canStartReview?: boolean;
+
+ @property({type: Object, observer: '_changeChanged'})
+ _change?: ChangeInfo | ParsedChangeInfo;
+
+ @property({type: Object, computed: '_getRevisionInfo(_change)'})
+ _revisionInfo?: RevisionInfoClass;
+
+ @property({type: Object})
+ _commitInfo?: CommitInfo;
+
+ @property({
+ type: Object,
+ computed:
+ '_computeCurrentRevision(_change.current_revision, ' +
+ '_change.revisions)',
+ observer: '_handleCurrentRevisionUpdate',
+ })
+ _currentRevision?: RevisionInfo;
+
+ @property({type: String})
+ _changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ _diffDrafts?: {[path: string]: UIDraft[]} = {};
+
+ @property({type: Boolean})
+ _editingCommitMessage = false;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeHideEditCommitMessage(_loggedIn, ' +
+ '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+ '_commitCollapsible)',
+ })
+ _hideEditCommitMessage?: boolean;
+
+ @property({type: String})
+ _diffAgainst?: string;
+
+ @property({type: String})
+ _latestCommitMessage: string | null = '';
+
+ @property({type: Object})
+ _constants = {
+ SecondaryTab,
+ PrimaryTab,
+ };
+
+ @property({type: Object})
+ _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+
+ @property({type: Number})
+ _lineHeight?: number;
+
+ @property({
+ type: String,
+ computed:
+ '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+ })
+ _changeIdCommitMessageError?: string;
+
+ @property({type: Object})
+ _patchRange?: PatchRange;
+
+ @property({type: String})
+ _filesExpanded?: string;
+
+ @property({type: String})
+ _basePatchNum?: string;
+
+ @property({type: Object})
+ _selectedRevision?: RevisionInfo;
+
+ @property({type: Object})
+ _currentRevisionActions?: ActionNameToActionInfoMap;
+
+ @property({
+ type: Array,
+ computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+ })
+ _allPatchSets?: PatchSet[];
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Boolean})
+ _loading?: boolean;
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({
+ type: String,
+ computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+ })
+ _replyButtonLabel = 'Reply';
+
+ @property({type: String})
+ _selectedPatchSet?: string;
+
+ @property({type: Number})
+ _shownFileCount?: number;
+
+ @property({type: Boolean})
+ _initialLoadComplete = false;
+
+ @property({type: Boolean})
+ _replyDisabled = true;
+
+ @property({type: String, computed: '_changeStatusString(_change)'})
+ _changeStatus?: string;
+
+ @property({
+ type: String,
+ computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+ })
+ _changeStatuses?: string;
+
+ /** If false, then the "Show more" button was used to expand. */
+ @property({type: Boolean})
+ _commitCollapsed = true;
+
+ /** Is the "Show more/less" button visible? */
+ @property({
+ type: Boolean,
+ computed: '_computeCommitCollapsible(_latestCommitMessage)',
+ })
+ _commitCollapsible?: boolean;
+
+ @property({type: Boolean})
+ _relatedChangesCollapsed = true;
+
+ @property({type: Number})
+ _updateCheckTimerHandle?: number | null;
+
+ @property({
+ type: Boolean,
+ computed: '_computeEditMode(_patchRange.*, params.*)',
+ })
+ _editMode?: boolean;
+
+ @property({type: Boolean, observer: '_updateToggleContainerClass'})
+ _showRelatedToggle = false;
+
+ @property({
+ type: Boolean,
+ computed: '_isParentCurrent(_currentRevisionActions)',
+ })
+ _parentIsCurrent?: boolean;
+
+ @property({
+ type: Boolean,
+ computed: '_isSubmitEnabled(_currentRevisionActions)',
+ })
+ _submitEnabled?: boolean;
+
+ @property({type: Boolean})
+ _mergeable: boolean | null = null;
+
+ @property({type: Boolean})
+ _showFileTabContent = true;
+
+ @property({type: Array})
+ _dynamicTabHeaderEndpoints: string[] = [];
+
+ @property({type: Array})
+ _dynamicTabContentEndpoints: string[] = [];
+
+ @property({type: String})
+ // The dynamic content of the plugin added tab
+ _selectedTabPluginEndpoint?: string;
+
+ @property({type: String})
+ // The dynamic heading of the plugin added tab
+ _selectedTabPluginHeader?: string;
+
+ @property({
+ type: Array,
+ computed:
+ '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
+ })
+ _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
+
+ @property({type: Number})
+ _currentRobotCommentsPatchSet?: PatchSetNum;
+
+ /**
+ * this is a two-element tuple to always
+ * hold the current active tab for both primary and secondary tabs
+ */
+ @property({type: Array})
+ _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+
+ @property({type: Boolean})
+ _showAllRobotComments = false;
+
+ @property({type: Boolean})
+ _showRobotCommentsButton = false;
+
+ _throttledToggleChangeStar?: EventListener;
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+ [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+ [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+ [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+ [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
+ [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+ [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
+ [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+ [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+ [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+ [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+ [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+ [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+ [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+ [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+ [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+ };
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ this._throttledToggleChangeStar = this._throttleWrap(e =>
+ this._handleToggleChangeStar(e as CustomKeyboardEvent)
+ );
+ }
+
+ /** @override */
+ created() {
+ super.created();
+
+ this.addEventListener('topic-changed', () => this._handleTopicChanged());
+
+ this.addEventListener(
+ // 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',
+ () => this._handleHideBackgroundContent()
+ );
+
+ this.addEventListener('fullscreen-overlay-closed', () =>
+ this._handleShowBackgroundContent()
+ );
+
+ this.addEventListener('diff-comments-modified', () =>
+ this._handleReloadCommentThreads()
+ );
+
+ this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getServerConfig().then(config => {
+ this._serverConfig = config;
+ this._replyDisabled = false;
+ });
+
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn) {
+ this.$.restAPI.getAccount().then(acct => {
+ this._account = acct;
+ });
+ }
+ this._setDiffViewMode();
+ });
+
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-tab-header'
+ );
+ this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-tab-content'
+ );
+ if (
+ this._dynamicTabContentEndpoints.length !==
+ this._dynamicTabHeaderEndpoints.length
+ ) {
+ console.warn('Different number of tab headers and tab content.');
+ }
+ })
+ .then(() => this._initActiveTabs(this.params));
+
+ this.addEventListener('comment-save', e => this._handleCommentSave(e));
+ this.addEventListener('comment-refresh', () => this._reloadDrafts());
+ this.addEventListener('comment-discard', e =>
+ this._handleCommentDiscard(e)
+ );
+ this.addEventListener('change-message-deleted', () => this._reload());
+ this.addEventListener('editable-content-save', e =>
+ this._handleCommitMessageSave(e as CustomEvent<{content: string}>)
+ );
+ this.addEventListener('editable-content-cancel', () =>
+ this._handleCommitMessageCancel()
+ );
+ this.addEventListener('open-fix-preview', e =>
+ this._onOpenFixPreview(e as CustomEvent<CommentEventDetail>)
+ );
+ this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
+ this.listen(window, 'scroll', '_handleScroll');
+ this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+
+ this.addEventListener('show-primary-tab', e =>
+ this._setActivePrimaryTab(e as CustomEvent<SwitchTabEventDetail>)
+ );
+ this.addEventListener('show-secondary-tab', e =>
+ this._setActiveSecondaryTab(e as CustomEvent<SwitchTabEventDetail>)
+ );
+ this.addEventListener('reload', e => {
+ e.stopPropagation();
+ const evt = e as CustomEvent<{clearPatchset: boolean}>;
+ this._reload(
+ /* isLocationChange= */ false,
+ /* clearPatchset= */ evt.detail && evt.detail.clearPatchset
+ );
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.unlisten(window, 'scroll', '_handleScroll');
+ this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+ if (this._updateCheckTimerHandle) {
+ this._cancelUpdateCheckTimer();
+ }
+ }
+
+ get messagesList() {
+ return this.shadowRoot!.querySelector('gr-messages-list');
+ }
+
+ get threadList() {
+ return this.shadowRoot!.querySelector('gr-thread-list');
+ }
+
+ _changeStatusString(change: ChangeInfo) {
+ return changeStatusString(change);
+ }
+
+ _setDiffViewMode(opt_reset?: boolean) {
+ if (!opt_reset && this.viewState.diffViewMode) {
+ return;
+ }
+
+ return this._getPreferences()
+ .then(prefs => {
+ if (!this.viewState.diffMode && prefs) {
+ this.set('viewState.diffMode', prefs.default_diff_view);
+ }
+ })
+ .then(() => {
+ if (!this.viewState.diffMode) {
+ this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+ }
+ });
+ }
+
+ _onOpenFixPreview(e: CustomEvent<CommentEventDetail>) {
+ this.$.applyFixDialog.open(e);
+ }
+
+ _onCloseFixPreview() {
+ this._reload();
+ }
+
+ _handleToggleDiffMode(e: CustomKeyboardEvent) {
+ 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);
+ }
+ }
+
+ _isTabActive(tab: string, activeTabs: string[]) {
+ return activeTabs.includes(tab);
+ }
+
+ /**
+ * Actual implementation of switching a tab
+ *
+ * @param paperTabs - the parent tabs container
+ */
+ _setActiveTab(
+ paperTabs: PaperTabsElement,
+ activeDetails: {
+ activeTabName?: string;
+ activeTabIndex?: number;
+ scrollIntoView?: boolean;
+ }
+ ) {
+ const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+ const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
+ HTMLElement
+ >;
+ let activeIndex = -1;
+ if (activeTabIndex !== undefined) {
+ activeIndex = activeTabIndex;
+ } else {
+ for (let i = 0; i <= tabs.length; i++) {
+ const tab = tabs[i];
+ if (tab.dataset['name'] === activeTabName) {
+ activeIndex = i;
+ break;
+ }
+ }
+ }
+ if (activeIndex === -1) {
+ console.warn('tab not found with given info', activeDetails);
+ return;
+ }
+ const tabName = tabs[activeIndex].dataset['name'];
+ if (scrollIntoView) {
+ paperTabs.scrollIntoView();
+ }
+ if (paperTabs.selected !== activeIndex) {
+ paperTabs.selected = activeIndex;
+ this.reporting.reportInteraction('show-tab', {tabName});
+ }
+ return tabName;
+ }
+
+ /**
+ * Changes active primary tab.
+ */
+ _setActivePrimaryTab(e: CustomEvent<SwitchTabEventDetail>) {
+ const primaryTabs = this.shadowRoot!.querySelector(
+ '#primaryTabs'
+ ) as PaperTabsElement;
+ const activeTabName = this._setActiveTab(primaryTabs, {
+ activeTabName: e.detail.tab,
+ activeTabIndex: e.detail.value,
+ scrollIntoView: e.detail.scrollIntoView,
+ });
+ if (activeTabName) {
+ this._activeTabs = [activeTabName, this._activeTabs[1]];
+
+ // update plugin endpoint if its a plugin tab
+ const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+ activeTabName
+ );
+ if (pluginIndex !== -1) {
+ this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+ pluginIndex
+ ];
+ this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+ pluginIndex
+ ];
+ } else {
+ this._selectedTabPluginEndpoint = '';
+ this._selectedTabPluginHeader = '';
+ }
+ }
+ }
+
+ /**
+ * Changes active secondary tab.
+ */
+ _setActiveSecondaryTab(e: CustomEvent<SwitchTabEventDetail>) {
+ const secondaryTabs = this.shadowRoot!.querySelector(
+ '#secondaryTabs'
+ ) as PaperTabsElement;
+ const activeTabName = this._setActiveTab(secondaryTabs, {
+ activeTabName: e.detail.tab,
+ activeTabIndex: e.detail.value,
+ scrollIntoView: e.detail.scrollIntoView,
+ });
+ if (activeTabName) {
+ this._activeTabs = [this._activeTabs[0], activeTabName];
+ }
+ }
+
+ _handleEditCommitMessage() {
+ this._editingCommitMessage = true;
+ this.$.commitMessageEditor.focusTextarea();
+ }
+
+ _handleCommitMessageSave(e: CustomEvent<{content: string}>) {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ // 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(() => {
+ this.$.commitMessageEditor.disabled = false;
+ });
+ }
+
+ _reloadWindow() {
+ window.location.reload();
+ }
+
+ _handleCommitMessageCancel() {
+ this._editingCommitMessage = false;
+ }
+
+ _computeChangeStatusChips(
+ change: ChangeInfo | undefined,
+ mergeable: boolean | null,
+ submitEnabled?: boolean
+ ) {
+ if (!change) {
+ return undefined;
+ }
+
+ // Show no chips until mergeability is loaded.
+ if (mergeable === null) {
+ return [];
+ }
+
+ const options = {
+ includeDerived: true,
+ mergeable: !!mergeable,
+ submitEnabled: !!submitEnabled,
+ };
+ return changeStatuses(change, options);
+ }
+
+ _computeHideEditCommitMessage(
+ loggedIn: boolean,
+ editing: boolean,
+ change: ChangeInfo,
+ editMode: boolean,
+ collapsed: boolean,
+ collapsible: boolean
+ ) {
+ if (
+ !loggedIn ||
+ editing ||
+ (change && change.status === ChangeStatus.MERGED) ||
+ editMode ||
+ (collapsed && collapsible)
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+ return threads.reduce((robotCommentCountMap, thread) => {
+ const comments = thread.comments;
+ const robotCommentsCount = comments.reduce(
+ (acc, comment) => (isRobot(comment) ? acc + 1 : acc),
+ 0
+ );
+ if (comments[0].patch_set)
+ robotCommentCountMap[`${comments[0].patch_set}`] =
+ (robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
+ robotCommentsCount;
+ return robotCommentCountMap;
+ }, {} as {[patchset: string]: number});
+ }
+
+ _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
+ const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+ const commentCnt = commentCount[patch._number] || 0;
+ if (commentCnt === 0) return `Patchset ${patch._number}`;
+ const findingsText = commentCnt === 1 ? 'finding' : 'findings';
+ return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+ }
+
+ _computeRobotCommentsPatchSetDropdownItems(
+ change: ChangeInfo,
+ commentThreads: CommentThread[]
+ ) {
+ if (!change || !commentThreads || !change.revisions) return [];
+
+ return Object.values(change.revisions)
+ .filter(patch => patch._number !== 'edit')
+ .map(patch => {
+ return {
+ text: this._computeText(patch, commentThreads),
+ value: patch._number,
+ };
+ })
+ .sort((a, b) => (b.value as number) - (a.value as number));
+ }
+
+ _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
+ this._currentRobotCommentsPatchSet = currentRevision._number;
+ }
+
+ _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+ const patchSet = parseInt(e.detail.value) as PatchSetNum;
+ if (patchSet === this._currentRobotCommentsPatchSet) return;
+ this._currentRobotCommentsPatchSet = patchSet;
+ }
+
+ _computeShowText(showAllRobotComments: boolean) {
+ return showAllRobotComments ? 'Show Less' : 'Show more';
+ }
+
+ _toggleShowRobotComments() {
+ this._showAllRobotComments = !this._showAllRobotComments;
+ }
+
+ _computeRobotCommentThreads(
+ commentThreads: CommentThread[],
+ currentRobotCommentsPatchSet: PatchSetNum,
+ showAllRobotComments: boolean
+ ) {
+ if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+ const threads = commentThreads.filter(thread => {
+ const comments = thread.comments || [];
+ return (
+ comments.length &&
+ isRobot(comments[0]) &&
+ comments[0].patch_set === currentRobotCommentsPatchSet
+ );
+ });
+ this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+ return threads.slice(
+ 0,
+ showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+ );
+ }
+
+ _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();
+ flush();
+ });
+ }
+
+ _handleReloadDiffComments(
+ e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
+ ) {
+ // 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
+ );
+ flush();
+ });
+ }
+
+ _computeTotalCommentCounts(
+ unresolvedCount: number,
+ changeComments: ChangeComments
+ ) {
+ if (!changeComments) return undefined;
+ 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: CustomEvent<{comment: DraftInfo}>) {
+ const draft = e.detail.comment;
+ if (!draft.__draft || !draft.path) return;
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+
+ 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 = {...this._diffDrafts};
+ if (!diffDrafts[draft.path]) {
+ diffDrafts[draft.path] = [draft];
+ this._diffDrafts = diffDrafts;
+ return;
+ }
+ for (let i = 0; i < diffDrafts[draft.path].length; i++) {
+ if (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.
+ (c1.line || -1) - (c2.line || -1)
+ );
+ this._diffDrafts = diffDrafts;
+ }
+
+ _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
+ const draft = e.detail.comment;
+ if (!draft.__draft || !draft.path) {
+ return;
+ }
+
+ if (!this._diffDrafts || !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;
+ }
+
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ 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 = {...this._diffDrafts};
+ diffDrafts[draft.path].splice(index, 1);
+ if (diffDrafts[draft.path].length === 0) {
+ delete diffDrafts[draft.path];
+ }
+ this._diffDrafts = diffDrafts;
+ }
+
+ _handleReplyTap(e: MouseEvent) {
+ e.preventDefault();
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ }
+
+ _handleOpenDiffPrefs() {
+ this.$.fileList.openDiffPrefs();
+ }
+
+ _handleOpenIncludedInDialog() {
+ this.$.includedInDialog.loadData().then(() => {
+ flush();
+ this.$.includedInOverlay.refit();
+ });
+ this.$.includedInOverlay.open();
+ }
+
+ _handleIncludedInDialogClose() {
+ this.$.includedInOverlay.close();
+ }
+
+ _handleOpenDownloadDialog() {
+ this.$.downloadOverlay.open().then(() => {
+ this.$.downloadOverlay.setFocusStops(
+ this.$.downloadDialog.getFocusStops()
+ );
+ this.$.downloadDialog.focus();
+ });
+ }
+
+ _handleDownloadDialogClose() {
+ this.$.downloadOverlay.close();
+ }
+
+ _handleOpenUploadHelpDialog() {
+ this.$.uploadHelpOverlay.open();
+ }
+
+ _handleCloseUploadHelpDialog() {
+ this.$.uploadHelpOverlay.close();
+ }
+
+ _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+ const msg: string = e.detail.message.message;
+ const quoteStr =
+ msg
+ .split('\n')
+ .map(line => '> ' + 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() {
+ this.addEventListener(
+ 'change-details-loaded',
+ () => {
+ this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+ },
+ {once: true}
+ );
+ this.$.replyOverlay.close();
+ this._reload();
+ }
+
+ _handleReplyCancel() {
+ this.$.replyOverlay.close();
+ }
+
+ _handleReplyAutogrow() {
+ // 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: CustomEvent<{value: {ccsOnly: boolean}}>) {
+ 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: CustomEvent<{length: number}>) {
+ this._shownFileCount = e.detail.length;
+ }
+
+ _expandAllDiffs(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ this.$.fileList.expandAllDiffs();
+ }
+
+ _collapseAllDiffs() {
+ this.$.fileList.collapseAllDiffs();
+ }
+
+ _paramsChanged(value: AppElementChangeViewParams) {
+ if (value.view !== GerritView.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);
+ const changeChanged = this._changeNum !== value.changeNum;
+
+ const patchRange = {
+ patchNum: value.patchNum,
+ basePatchNum: value.basePatchNum || 'PARENT',
+ };
+ // TODO(TS): remove once proper type for patchRange is defined
+ if (!isNaN(Number(patchRange.patchNum))) {
+ patchRange.patchNum = Number(patchRange.patchNum) as PatchSetNum;
+ }
+ if (!isNaN(Number(patchRange.basePatchNum))) {
+ patchRange.basePatchNum = Number(patchRange.basePatchNum) as PatchSetNum;
+ }
+
+ this.$.fileList.collapseAllDiffs();
+ // TODO(TS): change patchRange to PatchRange.
+ this._patchRange = patchRange as 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 (!changeChanged && patchChanged) {
+ if (!patchRange.patchNum) {
+ patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+ }
+ this._reloadPatchNumDependentResources().then(() => {
+ this._sendShowChangeEvent();
+ });
+ return;
+ }
+
+ this._initialLoadComplete = false;
+ this._changeNum = value.changeNum;
+ this.$.relatedChanges.clear();
+
+ this._reload(true).then(() => {
+ this._performPostLoadTasks();
+ });
+
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._initActiveTabs(value);
+ });
+ }
+
+ _initActiveTabs(params?: AppElementChangeViewParams) {
+ let primaryTab = PrimaryTab.FILES;
+ if (params && params.queryMap && params.queryMap.has('tab')) {
+ primaryTab = params.queryMap.get('tab') as PrimaryTab;
+ }
+ this._setActivePrimaryTab(
+ new CustomEvent('initActiveTab', {
+ detail: {
+ tab: primaryTab,
+ },
+ })
+ );
+ this._setActiveSecondaryTab(
+ new CustomEvent('initActiveTab', {
+ detail: {
+ tab: SecondaryTab.CHANGE_LOG,
+ },
+ })
+ );
+ }
+
+ _sendShowChangeEvent() {
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+ change: this._change,
+ patchNum: this._patchRange.patchNum,
+ info: {mergeable: this._mergeable},
+ });
+ }
+
+ _performPostLoadTasks() {
+ this._maybeShowReplyDialog();
+ this._maybeShowRevertDialog();
+ this._maybeShowDownloadDialog();
+
+ this._sendShowChangeEvent();
+
+ this.async(() => {
+ if (this.viewState.scrollTop) {
+ document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
+ } else {
+ this._maybeScrollToMessage(window.location.hash);
+ }
+ this._initialLoadComplete = true;
+ });
+ }
+
+ @observe('params', '_change')
+ _paramsAndChangeChanged(
+ value?: AppElementChangeViewParams,
+ change?: ChangeInfo
+ ) {
+ // Polymer 2: check for undefined
+ if (!value || !change) {
+ return;
+ }
+
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ // 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 ||
+ patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+ patchRangeState.patchNum !== this._patchRange.patchNum
+ ) {
+ this._resetFileListViewState();
+ }
+ }
+
+ _viewStateChanged(viewState: ChangeViewState) {
+ this._numFilesShown = viewState.numFilesShown
+ ? viewState.numFilesShown
+ : DEFAULT_NUM_FILES_SHOWN;
+ }
+
+ _numFilesShownChanged(numFilesShown: number) {
+ this.viewState.numFilesShown = numFilesShown;
+ }
+
+ _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const hash = MSG_PREFIX + e.detail.id;
+ const url = GerritNav.getUrlForChange(
+ this._change,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum,
+ this._editMode,
+ hash
+ );
+ history.replaceState(null, '', url);
+ }
+
+ _maybeScrollToMessage(hash: string) {
+ if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
+ this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+ }
+ }
+
+ _getLocationSearch() {
+ // Not inlining to make it easier to test.
+ return window.location.search;
+ }
+
+ _getUrlParameter(param: string) {
+ 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() {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => this._getLoggedIn())
+ .then(loggedIn => {
+ if (
+ !loggedIn ||
+ !this._change ||
+ this._change.status !== 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);
+ }
+ });
+ }
+
+ _maybeShowDownloadDialog() {
+ if (this.viewState.showDownloadDialog) {
+ this._handleOpenDownloadDialog();
+ this.set('viewState.showDownloadDialog', 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?: ChangeInfo | ParsedChangeInfo) {
+ if (!change || !this._patchRange || !this._allPatchSets) {
+ return;
+ }
+
+ // We get the parent first so we keep the original value for basePatchNum
+ // and not the updated value.
+ const parent = this._getBasePatchNum(change, this._patchRange);
+
+ this.set(
+ '_patchRange.patchNum',
+ this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
+ );
+
+ this.set('_patchRange.basePatchNum', parent);
+
+ const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ /**
+ * Gets base patch number, if it is a parent try and decide from
+ * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+ */
+ _getBasePatchNum(
+ change: ChangeInfo | ParsedChangeInfo,
+ patchRange: 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 = hasOwnProperty(parentCounts, 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';
+ }
+
+ _computeChangeUrl(change: ChangeInfo) {
+ return GerritNav.getUrlForChange(change);
+ }
+
+ _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
+ return changeStatus === 'Merged' && current_revision;
+ }
+
+ _computeMergedCommitInfo(
+ current_revision: CommitId,
+ revisions: {[revisionId: string]: RevisionInfo}
+ ) {
+ 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: string) {
+ return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+ }
+
+ _computeTitleAttributeWarning(displayChangeId: string) {
+ if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+ return 'Change-Id mismatch';
+ } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+ return 'No Change-Id in commit message';
+ }
+ return undefined;
+ }
+
+ _computeChangeIdCommitMessageError(
+ commitMessage?: string,
+ change?: ChangeInfo
+ ) {
+ if (change === undefined) {
+ return undefined;
+ }
+
+ 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[2];
+ }
+
+ 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;
+ }
+
+ _computeReplyButtonLabel(
+ changeRecord?: ElementPropertyDeepChange<
+ GrChangeView,
+ '_diffDrafts'
+ > | null,
+ canStartReview?: PolymerDeepPropertyChange<boolean, boolean>
+ ) {
+ if (changeRecord === undefined || canStartReview === undefined) {
+ return 'Reply';
+ }
+
+ const drafts = (changeRecord && changeRecord.base) || {};
+ const draftCount = Object.keys(drafts).reduce(
+ (count, file) => count + drafts[file].length,
+ 0
+ );
+
+ let label = canStartReview ? 'Start Review' : 'Reply';
+ if (draftCount > 0) {
+ label += ` (${draftCount})`;
+ }
+ return label;
+ }
+
+ _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ this._getLoggedIn().then(isLoggedIn => {
+ if (!isLoggedIn) {
+ this.dispatchEvent(
+ new CustomEvent('show-auth-required', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ e.preventDefault();
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ });
+ }
+
+ _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._handleOpenDownloadDialog();
+ }
+
+ _handleEditTopic(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.metadata.editTopic();
+ }
+
+ _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Base is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+ }
+
+ _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Left is already base.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+ }
+
+ _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Latest is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(
+ this._change,
+ latestPatchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Right is already latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(
+ this._change,
+ latestPatchNum,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (
+ patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+ patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Already diffing base against latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, latestPatchNum);
+ }
+
+ _handleRefreshChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ e.preventDefault();
+ this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+ }
+
+ _handleToggleChangeStar(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.$.changeStar.toggleStar();
+ }
+
+ _handleUpToDashboard(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._determinePageBack();
+ }
+
+ _handleExpandAllMessages(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.messagesList) {
+ this.messagesList.handleExpandCollapse(true);
+ }
+ }
+
+ _handleCollapseAllMessages(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.messagesList) {
+ this.messagesList.handleExpandCollapse(false);
+ }
+ }
+
+ _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
+ 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.
+ GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+ }
+
+ _handleLabelRemoved(
+ splices: Array<PolymerSplice<ApprovalInfo[]>>,
+ path: string
+ ) {
+ 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) as QuickLabelInfo;
+ if (
+ labelDict.approved &&
+ labelDict.approved._account_id === removed._account_id
+ ) {
+ this._reload();
+ return;
+ }
+ }
+ }
+ }
+
+ @observe('_change.labels.*')
+ _labelsChanged(
+ changeRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ PolymerSpliceChange<ApprovalInfo[]>
+ >
+ ) {
+ if (!changeRecord) {
+ return;
+ }
+ if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
+ this._handleLabelRemoved(
+ changeRecord.value.indexSplices,
+ changeRecord.path
+ );
+ }
+ this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+ change: this._change,
+ });
+ }
+
+ _openReplyDialog(section?: FocusTarget) {
+ this.$.replyOverlay.open().finally(() => {
+ // the following code should be executed no matter open succeed or not
+ this._resetReplyOverlayFocusStops();
+ this.$.replyDialog.open(section);
+ flush();
+ this.$.replyOverlay.center();
+ });
+ }
+
+ _handleGetChangeDetailError(response?: Response | null) {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getServerConfig() {
+ return this.$.restAPI.getConfig();
+ }
+
+ _getProjectConfig() {
+ if (!this._change) throw new Error('missing required change property');
+ return this.$.restAPI
+ .getProjectConfig(this._change.project)
+ .then(config => {
+ this._projectConfig = config;
+ });
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ _prepareCommitMsgForLinkify(msg: string) {
+ // 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.
+ */
+ _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+ if (!edit) return;
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
+ const changeWithEdit = change;
+ if (changeWithEdit.revisions)
+ changeWithEdit.revisions[edit.commit.commit] = {
+ _number: EditPatchSetNum,
+ basePatchNum: edit.base_patch_set_number,
+ commit: edit.commit,
+ fetch: edit.fetch,
+ } as RevisionInfo;
+
+ // 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 &&
+ changeWithEdit.current_revision === edit.base_revision
+ ) {
+ changeWithEdit.current_revision = edit.commit.commit;
+ this.set('_patchRange.patchNum', EditPatchSetNum);
+ // 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
+ if (changeWithEdit.revisions) {
+ changeWithEdit.revisions[edit.commit.commit].actions =
+ changeWithEdit.revisions[edit.base_revision].actions;
+ }
+ }
+ }
+
+ _getChangeDetail() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
+ this._handleGetChangeDetailError(r)
+ );
+ const editCompletes = this._getEdit();
+ const prefCompletes = this._getPreferences();
+
+ return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
+ ([change, edit, prefs]) => {
+ this._prefs = prefs;
+
+ if (!change) {
+ return false;
+ }
+ this._processEdit(change, edit);
+ // Issue 4190: Coalesce missing topics to null.
+ // TODO(TS): code needs second thought,
+ // it might be that nulls were assigned to trigger some bindings
+ if (!change.topic) {
+ change.topic = (null as unknown) as undefined;
+ }
+ if (!change.reviewer_updates) {
+ change.reviewer_updates = (null as unknown) as undefined;
+ }
+ const latestRevisionSha = this._getLatestRevisionSHA(change);
+ if (!latestRevisionSha)
+ throw new Error('Could not find latest Revision Sha');
+ 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 ||
+ patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+ ) {
+ // CommitInfo.commit is optional, and may need patching.
+ if (currentRevision.commit && !currentRevision.commit.commit) {
+ currentRevision.commit.commit = latestRevisionSha as CommitId;
+ }
+ this._commitInfo = currentRevision.commit;
+ this._selectedRevision = currentRevision;
+ // TODO: Fetch and process files.
+ } else {
+ if (!this._change?.revisions || !this._patchRange) return false;
+ this._selectedRevision = Object.values(this._change.revisions).find(
+ revision => {
+ // edit patchset is a special one
+ const thePatchNum = this._patchRange!.patchNum;
+ if (thePatchNum === 'edit') {
+ return revision._number === thePatchNum;
+ }
+ return revision._number === parseInt(`${thePatchNum}`, 10);
+ }
+ );
+ }
+ return false;
+ }
+ );
+ }
+
+ _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
+ return !!(
+ revisionActions &&
+ revisionActions.submit &&
+ revisionActions.submit.enabled
+ );
+ }
+
+ _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+ if (revisionActions && revisionActions.rebase) {
+ return !revisionActions.rebase.enabled;
+ } else {
+ return true;
+ }
+ }
+
+ _getEdit() {
+ if (!this._changeNum)
+ return Promise.reject(new Error('missing required changeNum property'));
+ return this.$.restAPI.getChangeEdit(this._changeNum, true);
+ }
+
+ _getLatestCommitMessage() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (lastpatchNum === undefined)
+ throw new Error('missing lastPatchNum property');
+ return this.$.restAPI
+ .getChangeCommitInfo(this._changeNum, lastpatchNum)
+ .then(commitInfo => {
+ if (!commitInfo) return;
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+ commitInfo.message
+ );
+ });
+ }
+
+ _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+ 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 as PatchSetNum;
+ for (const rev in change.revisions) {
+ if (!hasOwnProperty(change.revisions, rev)) {
+ continue;
+ }
+
+ if (change.revisions[rev]._number > latestPatchNum) {
+ latestRev = rev;
+ latestPatchNum = change.revisions[rev]._number;
+ }
+ }
+ return latestRev;
+ }
+
+ _getCommitInfo() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (this._patchRange.patchNum === undefined)
+ throw new Error('missing required patchNum property');
+ return this.$.restAPI
+ .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+ .then(commitInfo => {
+ this._commitInfo = commitInfo;
+ });
+ }
+
+ _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
+ return this._reloadDrafts().then(() => e.detail.resolve());
+ }
+
+ /**
+ * Fetches a new changeComment object, and data for all types of comments
+ * (comments, robot comments, draft comments) is requested.
+ */
+ _reloadComments() {
+ // We are resetting all comment related properties, because we want to avoid
+ // a new change being loaded and then paired with outdated comments.
+ this._changeComments = undefined;
+ this._commentThreads = undefined;
+ this._diffDrafts = undefined;
+ this._draftCommentThreads = undefined;
+ this._robotCommentThreads = undefined;
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ return this.$.commentAPI
+ .loadAll(this._changeNum)
+ .then(comments => this._recomputeComments(comments));
+ }
+
+ /**
+ * Fetches a new changeComment object, but only updated data for drafts is
+ * requested.
+ *
+ * TODO(taoalpha): clean up this and _reloadComments, as single comment
+ * can be a thread so it does not make sense to only update drafts
+ * without updating threads
+ */
+ _reloadDrafts() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ return this.$.commentAPI
+ .reloadDrafts(this._changeNum)
+ .then(comments => this._recomputeComments(comments));
+ }
+
+ _recomputeComments(comments: ChangeComments) {
+ this._changeComments = comments;
+ this._diffDrafts = {...this._changeComments.drafts};
+ this._commentThreads = this._changeComments.getAllThreadsForChange();
+ this._draftCommentThreads = this._commentThreads
+ .filter(isDraftThread)
+ .map(thread => {
+ const copiedThread = {...thread};
+ // Make a hardcopy of all comments and collapse all but last one
+ const commentsInThread = (copiedThread.comments = thread.comments.map(
+ comment => {
+ return {...comment, collapsed: true as boolean};
+ }
+ ));
+ commentsInThread[commentsInThread.length - 1].collapsed = false;
+ return copiedThread;
+ });
+ }
+
+ /**
+ * Reload the change.
+ *
+ * @param isLocationChange Reloads the related changes
+ * when true and ends reporting events that started on location change.
+ * @param clearPatchset Reloads the related changes
+ * ignoring any patchset choice made.
+ * @return 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(isLocationChange?: boolean, clearPatchset?: boolean) {
+ if (clearPatchset && this._change) {
+ GerritNav.navigateToChange(this._change);
+ return Promise.resolve([]);
+ }
+ this._loading = true;
+ this._relatedChangesCollapsed = true;
+ this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+ this.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+ // Array to house all promises related to data requests.
+ const allDataPromises: Promise<unknown>[] = [];
+
+ // 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;
+ this.dispatchEvent(
+ new CustomEvent('change-details-loaded', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ })
+ .then(() => {
+ this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+ if (isLocationChange) {
+ 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 && 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 = Promise.all([mergeabilityLoaded]);
+ }
+
+ if (isLocationChange) {
+ this._editingCommitMessage = false;
+ const relatedChangesLoaded = coreDataPromise.then(() =>
+ this.$.relatedChanges.reload()
+ );
+ allDataPromises.push(relatedChangesLoaded);
+ }
+
+ Promise.all(allDataPromises).then(() => {
+ this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+ if (isLocationChange) {
+ 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 (!this._change) {
+ this._mergeable = null;
+ return Promise.resolve();
+ }
+ // 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 === ChangeStatus.MERGED ||
+ this._change.status === ChangeStatus.ABANDONED
+ ) {
+ this._mergeable = false;
+ return Promise.resolve();
+ }
+
+ if (!this._changeNum) {
+ return Promise.reject(new Error('missing required changeNum property'));
+ }
+
+ this._mergeable = null;
+ return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
+ if (mergableInfo) {
+ this._mergeable = mergableInfo.mergeable;
+ }
+ });
+ }
+
+ _computeCanStartReview(change: ChangeInfo) {
+ return !!(
+ change.actions &&
+ change.actions.ready &&
+ change.actions.ready.enabled
+ );
+ }
+
+ _computeReplyDisabled() {
+ return false;
+ }
+
+ _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
+ return `Change ${changeNum}`;
+ }
+
+ _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+ return collapsible && collapsed;
+ }
+
+ _computeRelatedChangesClass(collapsed: boolean) {
+ return collapsed ? 'collapsed' : '';
+ }
+
+ _computeCollapseText(collapsed: boolean) {
+ // Symbols are up and down triangles.
+ return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+ }
+
+ /**
+ * Returns the text to be copied when
+ * click the copy icon next to change subject
+ */
+ _computeCopyTextForTitle(change: ChangeInfo) {
+ return (
+ `${change._number}: ${change.subject} | ` +
+ `${location.protocol}//${location.host}` +
+ `${this._computeChangeUrl(change)}`
+ );
+ }
+
+ _toggleCommitCollapsed() {
+ this._commitCollapsed = !this._commitCollapsed;
+ if (this._commitCollapsed) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ _toggleRelatedChangesCollapsed() {
+ this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+ if (this._relatedChangesCollapsed) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ _computeCommitCollapsible(commitMessage?: string) {
+ if (!commitMessage) {
+ return false;
+ }
+ return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+ }
+
+ _getOffsetHeight(element: HTMLElement) {
+ return element.offsetHeight;
+ }
+
+ _getScrollHeight(element: HTMLElement) {
+ return element.scrollHeight;
+ }
+
+ /**
+ * Get the line height of an element to the nearest integer.
+ */
+ _getLineHeight(element: Element) {
+ const lineHeightStr = getComputedStyle(element).lineHeight;
+ return Math.round(Number(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;
+
+ 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 (this._commitCollapsible) {
+ // 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: {[key: string]: string} = {};
+
+ // 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 (this._commitCollapsible) {
+ 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.
+ if (!getComputedStyleValue('--relation-chain-max-height', this)) {
+ 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);
+ }
+ return (this._showRelatedToggle = false);
+ }
+
+ _updateToggleContainerClass(showRelatedToggle: boolean) {
+ 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(() => {
+ if (!this._change) throw new Error('missing required change property');
+ const change = this._change;
+ fetchChangeUpdates(change, this.$.restAPI).then(result => {
+ let toastMessage = null;
+ if (!result.isLatest) {
+ toastMessage = ReloadToastMessage.NEWER_REVISION;
+ } else if (result.newStatus === ChangeStatus.MERGED) {
+ toastMessage = ReloadToastMessage.MERGED;
+ } else if (result.newStatus === ChangeStatus.ABANDONED) {
+ toastMessage = ReloadToastMessage.ABANDONED;
+ } else if (result.newStatus === ChangeStatus.NEW) {
+ toastMessage = ReloadToastMessage.RESTORED;
+ } else if (result.newMessages) {
+ toastMessage = ReloadToastMessage.NEW_MESSAGE;
+ }
+
+ // We have to make sure that the update is still relevant for the user.
+ // Since starting to fetch the change update the user may have sent a
+ // reply, or the change might have been reloaded, or it could be in the
+ // process of being reloaded.
+ const changeWasReloaded = change !== this._change;
+ if (!toastMessage || this._loading || changeWasReloaded) {
+ this._startUpdateCheckTimer();
+ return;
+ }
+
+ this._cancelUpdateCheckTimer();
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: toastMessage,
+ // Persist this alert.
+ dismissOnNavigation: true,
+ action: 'Reload',
+ callback: () => {
+ this._reload(
+ /* isLocationChange= */ false,
+ /* clearPatchset= */ true
+ );
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }, 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: boolean) {
+ const classes = ['header'];
+ if (editMode) {
+ classes.push('editMode');
+ }
+ return classes.join(' ');
+ }
+
+ _computeEditMode(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+ paramsRecord: PolymerDeepPropertyChange<
+ AppElementChangeViewParams,
+ AppElementChangeViewParams
+ >
+ ) {
+ if (!patchRangeRecord || !paramsRecord) {
+ return undefined;
+ }
+
+ if (paramsRecord.base && paramsRecord.base.edit) {
+ return true;
+ }
+
+ const patchRange = patchRangeRecord.base || {};
+ return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+ }
+
+ _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+ e.preventDefault();
+ const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+ '#editControls'
+ ) as GrEditControls | null;
+ if (!controls) throw new Error('Missing edit controls');
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const path = e.detail.path;
+ switch (e.detail.action) {
+ case GrEditConstants.Actions.DELETE.id:
+ controls.openDeleteDialog(path);
+ break;
+ case GrEditConstants.Actions.OPEN.id:
+ GerritNav.navigateToRelativeUrl(
+ GerritNav.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: NumericChangeId, revision: CommitId) {
+ return `c${number}_rev${revision}`;
+ }
+
+ @observe('_patchRange.patchNum')
+ _patchNumChanged(patchNumStr: PatchSetNum) {
+ if (!this._selectedRevision) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+
+ let patchNum: PatchSetNum;
+ if (patchNumStr === 'edit') {
+ patchNum = EditPatchSetNum;
+ } else {
+ patchNum = parseInt(`${patchNumStr}`, 10) as PatchSetNum;
+ }
+
+ if (patchNum === this._selectedRevision._number) {
+ return;
+ }
+ if (this._change.revisions)
+ 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() {
+ if (!this._change || !this._change.revisions)
+ throw new Error('missing required change property');
+ const editInfo = Object.values(this._change.revisions).find(
+ info => info._number === EditPatchSetNum
+ );
+
+ if (editInfo) {
+ GerritNav.navigateToChange(this._change, EditPatchSetNum);
+ return;
+ }
+
+ // Avoid putting patch set in the URL unless a non-latest patch set is
+ // selected.
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ let patchNum;
+ if (
+ !patchNumEquals(
+ this._patchRange.patchNum,
+ computeLatestPatchNum(this._allPatchSets)
+ )
+ ) {
+ patchNum = this._patchRange.patchNum;
+ }
+ GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+ }
+
+ _handleStopEditTap() {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+ }
+
+ _resetReplyOverlayFocusStops() {
+ this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+ }
+
+ _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+ return new RevisionInfoClass(change);
+ }
+
+ _computeCurrentRevision(
+ currentRevision: CommitId,
+ revisions: {[revisionId: string]: RevisionInfo}
+ ) {
+ return currentRevision && revisions && revisions[currentRevision];
+ }
+
+ _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
+ return disableDiffPrefs || !loggedIn;
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+ return computeLatestPatchNum(allPatchSets);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+ return hasEditBasedOnCurrentPatchSet(allPatchSets);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _hasEditPatchsetLoaded(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+ ) {
+ const patchRange = patchRangeRecord.base;
+ if (!patchRange) {
+ return false;
+ }
+ return hasEditPatchsetLoaded(patchRange);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeAllPatchSets(change: ChangeInfo) {
+ return computeAllPatchSets(change);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-view': GrChangeView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 6170bea..4fcbc78 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -300,6 +300,7 @@
_fetchSharedCacheURL() { return Promise.resolve({}); },
});
element = fixture.instantiate();
+ element._changeNum = '1';
sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
getPluginLoader().loadPlugins([]);
pluginApi.install(
@@ -333,6 +334,11 @@
basePatchNum: 'PARENT',
patchNum: 1,
};
+ element._change = {
+ _number: '1',
+ project: '',
+ change_id: '1',
+ };
const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
const replaceStateStub = sinon.stub(history, 'replaceState');
element._handleMessageAnchorTap({detail: {id: 'a12345'}});
@@ -414,6 +420,7 @@
suite('plugins adding to file tab', () => {
setup(done => {
+ element._changeNum = '1';
// Resolving it here instead of during setup() as other tests depend
// on flush() not being called during setup.
flush(() => done());
@@ -459,6 +466,7 @@
queryMap.set('tab', PrimaryTab.FINDINGS);
// view is required
element.params = {
+ changeNum: '1',
view: GerritNav.View.CHANGE,
...element.params, queryMap};
flush(() => {
@@ -473,6 +481,7 @@
queryMap.set('tab', 'random');
// view is required
element.params = {
+ changeNum: '1',
view: GerritNav.View.CHANGE,
...element.params, queryMap};
flush(() => {
@@ -783,6 +792,7 @@
getAllThreadsForChange: () => ([]),
computeDraftCount: () => 1,
}));
+ element._changeNum = '1';
});
test('drafts are reloaded when reload-drafts fired', done => {
@@ -1415,6 +1425,7 @@
});
test('_handleCommitMessageSave trims trailing whitespace', () => {
+ element._change = {};
const putStub = sinon.stub(element.$.restAPI, 'putChangeCommitMessage')
.returns(Promise.resolve({}));
@@ -1609,14 +1620,16 @@
});
test('_openReplyDialog called with `ANY` when coming from tap event',
- () => {
- const openStub = sinon.stub(element, '_openReplyDialog');
- element._serverConfig = {};
- MockInteractions.tap(element.$.replyBtn);
- assert(openStub.lastCall.calledWithExactly(
- element.$.replyDialog.FocusTarget.ANY),
- '_openReplyDialog should have been passed ANY');
- assert.equal(openStub.callCount, 1);
+ done => {
+ flush(() => {
+ const openStub = sinon.stub(element, '_openReplyDialog');
+ MockInteractions.tap(element.$.replyBtn);
+ assert(openStub.lastCall.calledWithExactly(
+ element.$.replyDialog.FocusTarget.ANY),
+ '_openReplyDialog should have been passed ANY');
+ assert.equal(openStub.callCount, 1);
+ done();
+ });
});
test('_openReplyDialog called with `BODY` when coming from message reply' +
@@ -1806,10 +1819,13 @@
});
});
- test('reply button is disabled until server config is loaded', () => {
+ test('reply button is disabled until server config is loaded', done => {
assert.isTrue(element._replyDisabled);
- element._serverConfig = {};
- assert.isFalse(element._replyDisabled);
+ // fetches the server config on attached
+ flush(() => {
+ assert.isFalse(element._replyDisabled);
+ done();
+ });
});
suite('commit message expand/collapse', () => {
@@ -2189,6 +2205,11 @@
basePatchNum: 'PARENT',
patchNum: 1,
};
+ element._change = {
+ _number: '1',
+ project: '',
+ change_id: '1',
+ };
const fileList = element.$.fileList;
const Actions = GrEditConstants.Actions;
element.$.fileListHeader.editMode = true;
@@ -2371,6 +2392,11 @@
});
test('_handleStopEditTap', done => {
+ element._change = {
+ _number: '1',
+ project: '',
+ change_id: '1',
+ };
sinon.stub(element.$.metadata, '_computeLabelNames');
navigateToChangeStub.restore();
sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index f5e3588..1957f5c 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -65,7 +65,7 @@
loadData() {
if (!this.changeNum) {
- return;
+ return Promise.reject(new Error('missing required property changeNum'));
}
this._filterText = '';
return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index b3478b1..85f5b71 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -113,7 +113,7 @@
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
-enum FocusTarget {
+export enum FocusTarget {
ANY = 'any',
BODY = 'body',
CCS = 'cc',
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 448b281..29eef48 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -105,6 +105,15 @@
leftSide?: boolean;
commentLink?: boolean;
}
+export interface AppElementChangeViewParams {
+ view: GerritView.CHANGE;
+ changeNum: NumericChangeId;
+ project: RepoName;
+ edit?: boolean;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ queryMap?: Map<string, string> | URLSearchParams;
+}
export interface AppElementJustRegisteredParams {
// We use params.view === ... as a type guard.
@@ -120,6 +129,7 @@
| AppElementDashboardParams
| AppElementGroupParams
| AppElementAdminParams
+ | AppElementChangeViewParams
| AppElementRepoParams
| AppElementDocSearchParams
| AppElementPluginScreenParams
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 8c26d4a..626c2dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -35,6 +35,7 @@
} from './gr-js-api-types';
import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
import {DiffLayer, HighlightJS} from '../../../types/types';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
const elements: {[key: string]: HTMLElement} = {};
const eventCallbacks: {[key: string]: EventCallback[]} = {};
@@ -177,7 +178,7 @@
}
}
- handleCommitMessage(change: ChangeInfo, msg: string) {
+ handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
try {
cb(change, msg);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 15cdac4..75af8a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -139,6 +139,7 @@
GroupName,
Hashtag,
TopMenuEntryInfo,
+ MergeableInfo,
} from '../../../types/common';
import {
CancelConditionCallback,
@@ -1551,7 +1552,7 @@
endpoint: '/commit?links',
patchNum,
reportEndpointAsIs: true,
- });
+ }) as Promise<CommitInfo | undefined>;
}
getChangeFiles(
@@ -3582,7 +3583,7 @@
changeNum,
endpoint: '/revisions/current/mergeable',
reportEndpointAsIs: true,
- });
+ }) as Promise<MergeableInfo | undefined>;
}
deleteDraftComments(query: string): Promise<Response> {
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index 180fb2e..fadbfa7 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -17,6 +17,7 @@
import {patchNumEquals} from '../../../utils/patch-set-util';
import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
type RevNumberToParentCountMap = {[revNumber: number]: number};
@@ -26,7 +27,7 @@
* @param change A change object resulting from a change detail
* call that includes revision information.
*/
- constructor(private change: ChangeInfo) {}
+ constructor(private change: ChangeInfo | ParsedChangeInfo) {}
/**
* Get the largest number of parents of the commit in any revision. For
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 7ff65e2..950619b 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -98,6 +98,8 @@
Hashtag,
FileNameToFileInfoMap,
TopMenuEntryInfo,
+ MergeableInfo,
+ CommitInfo,
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
@@ -216,7 +218,7 @@
getChangeDetail(
changeNum: number | string,
- opt_errFn?: Function,
+ opt_errFn?: ErrorCallback,
opt_cancelCondition?: Function
): Promise<ParsedChangeInfo | null | undefined>;
@@ -851,4 +853,15 @@
getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+ getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
+
+ putChangeCommitMessage(
+ changeNum: NumericChangeId,
+ message: string
+ ): Promise<Response>;
+
+ getChangeCommitInfo(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<CommitInfo | undefined>;
}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index bd71f2c..81eae16 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -44,6 +44,7 @@
NotifyType,
EmailFormat,
AuthType,
+ MergeStrategy,
} from '../constants/constants';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
@@ -804,7 +805,7 @@
large_change: string;
reply_label: string;
reply_tooltip: string;
- update_delay: string;
+ update_delay: number;
submit_whole_topic: boolean;
disable_private_changes: boolean;
mergeability_computation_behavior: string;
@@ -2235,3 +2236,16 @@
topic?: TopicName;
allow_empty?: boolean;
}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export interface MergeableInfo {
+ submit_type: SubmitType;
+ strategy?: MergeStrategy;
+ mergeable: boolean;
+ commit_merged?: boolean;
+ content_merged?: boolean;
+ conflicts?: string[];
+ mergeable_into?: string[];
+}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 708b5d8..3bb8e37 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -20,6 +20,7 @@
import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {CommitId, NumericChangeId, PatchRange, PatchSetNum} from './common';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
export function notUndefined<T>(x: T | undefined): x is T {
return x !== undefined;
@@ -213,3 +214,10 @@
basePath?: string;
path: string;
}
+
+export function isPolymerSpliceChange<
+ T,
+ U extends Array<{} | null | undefined>
+>(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
+ return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 962278d..48ef367 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -57,10 +57,6 @@
basePatchNum?: PatchSetNum;
}
-interface PatchRangeRecord {
- base: PatchRange;
-}
-
/**
* As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
* this function checks for patchNum equality.
@@ -246,7 +242,9 @@
export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
-export function computeLatestPatchNum(allPatchSets?: PatchSet[]) {
+export function computeLatestPatchNum(
+ allPatchSets?: PatchSet[]
+): PatchSetNum | undefined {
if (!allPatchSets || !allPatchSets.length) {
return undefined;
}
@@ -263,11 +261,7 @@
return allPatchSets[0].num === EditPatchSetNum;
}
-export function hasEditPatchsetLoaded(patchRangeRecord: PatchRangeRecord) {
- const patchRange = patchRangeRecord.base;
- if (!patchRange) {
- return false;
- }
+export function hasEditPatchsetLoaded(patchRange: PatchRange) {
return (
patchRange.patchNum === EditPatchSetNum ||
patchRange.basePatchNum === EditPatchSetNum
@@ -283,7 +277,7 @@
* meantime. The promise is rejected on network error.
*/
export function fetchChangeUpdates(
- change: ChangeInfo,
+ change: ChangeInfo | ParsedChangeInfo,
restAPI: RestApiService
) {
const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));