blob: 968f56fd1cd7c6b4fdf3b522dba517da022130e2 [file] [log] [blame]
/**
* @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 '../../../scripts/bundled-polymer.js';
import '@polymer/paper-tabs/paper-tabs.js';
import '../../../styles/shared-styles.js';
import '../../core/gr-reporting/gr-reporting.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-file-list/gr-file-list.js';
import '../gr-included-in-dialog/gr-included-in-dialog.js';
import '../gr-messages-list/gr-messages-list.js';
import '../gr-messages-list/gr-messages-list-experimental.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 {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.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 {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
import {RESTClientBehavior} from '../../../behaviors/rest-client-behavior/rest-client-behavior.js';
import {GrEditConstants} from '../../edit/gr-edit-constants.js';
import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
import {util} from '../../../scripts/util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
import {PrimaryTabs, SecondaryTabs} from '../../../constants/constants.js';
import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
import {appContext} from '../../../services/app-context.js';
const CHANGE_ID_ERROR = {
MISMATCH: 'mismatch',
MISSING: 'missing',
};
const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
const DEFAULT_NUM_FILES_SHOWN = 200;
const REVIEWERS_REGEX = /^(R|CC)=/gm;
const MIN_CHECK_INTERVAL_SECS = 0;
// These are the same as the breakpoint set in CSS. Make sure both are changed
// together.
const BREAKPOINT_RELATED_SMALL = '50em';
const BREAKPOINT_RELATED_MED = '75em';
// In the event that the related changes medium width calculation is too close
// to zero, provide some height.
const MINIMUM_RELATED_MAX_HEIGHT = 100;
const SMALL_RELATED_HEIGHT = 400;
const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
const MSG_PREFIX = '#message-';
const ReloadToastMessage = {
NEWER_REVISION: 'A newer patch set has been uploaded',
RESTORED: 'This change has been restored',
ABANDONED: 'This change has been abandoned',
MERGED: 'This change has been merged',
NEW_MESSAGE: 'There are new messages on this change',
};
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
UNIFIED: 'UNIFIED_DIFF',
};
const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
const 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
*/
/**
* @appliesMixin RESTClientMixin
* @appliesMixin PatchSetMixin
* @extends Polymer.Element
*/
class GrChangeView extends mixinBehaviors( [
KeyboardShortcutBehavior,
PatchSetBehavior,
RESTClientBehavior,
], 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)',
},
_comments: Object,
/** @type {?} */
_change: {
type: Object,
observer: '_changeChanged',
},
_revisionInfo: {
type: Object,
computed: '_getRevisionInfo(_change)',
},
/** @type {?} */
_commitInfo: Object,
_currentRevision: {
type: Object,
computed: '_computeCurrentRevision(_change.current_revision, ' +
'_change.revisions)',
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: {
SecondaryTabs,
PrimaryTabs,
},
},
_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.*)',
},
_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: [PrimaryTabs.FILES, SecondaryTabs.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 {
[this.Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
[this.Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
[this.Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
[this.Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
[this.Shortcut.OPEN_DOWNLOAD_DIALOG]:
'_handleOpenDownloadDialogShortcut',
[this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
[this.Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
[this.Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
[this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
[this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
[this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
[this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
};
}
constructor() {
super();
this.flagsService = appContext.flagsService;
}
/** @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());
}
/** @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();
});
pluginLoader.awaitPluginsLoaded()
.then(() => {
this._dynamicTabHeaderEndpoints =
pluginEndpoints.getDynamicEndpoints('change-view-tab-header');
this._dynamicTabContentEndpoints =
pluginEndpoints.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', this._handleCommentSave.bind(this));
this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
this.addEventListener('comment-discard',
this._handleCommentDiscard.bind(this));
this.addEventListener('change-message-deleted',
() => this._reload());
this.addEventListener('editable-content-save',
this._handleCommitMessageSave.bind(this));
this.addEventListener('editable-content-cancel',
this._handleCommitMessageCancel.bind(this));
this.addEventListener('open-fix-preview',
this._onOpenFixPreview.bind(this));
this.addEventListener('close-fix-preview',
this._onCloseFixPreview.bind(this));
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));
}
/** @override */
detached() {
super.detached();
this.unlisten(window, 'scroll', '_handleScroll');
this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
if (this._updateCheckTimerHandle) {
this._cancelUpdateCheckTimer();
}
}
_isChangeLogExperimentEnabled() {
return this.flagsService.isEnabled('UiFeature__cleaner_changelog');
}
get messagesList() {
const tagName = this._isChangeLogExperimentEnabled()
? 'gr-messages-list-experimental' : 'gr-messages-list';
return this.shadowRoot.querySelector(tagName);
}
get threadList() {
return this.shadowRoot.querySelector('gr-thread-list');
}
/**
* @param {boolean=} opt_reset
*/
_setDiffViewMode(opt_reset) {
if (!opt_reset && this.viewState.diffViewMode) { return; }
return this._getPreferences()
.then( prefs => {
if (!this.viewState.diffMode) {
this.set('viewState.diffMode', prefs.default_diff_view);
}
})
.then(() => {
if (!this.viewState.diffMode) {
this.set('viewState.diffMode', 'SIDE_BY_SIDE');
}
});
}
_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,
].some(arg => arg === 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 this.changeStatuses(change, options);
}
_computeHideEditCommitMessage(
loggedIn, editing, change, editMode, collapsed, collapsible) {
if (!loggedIn || editing ||
(change && change.status === this.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()
.map(c => Object.assign({}, c));
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 = Object.assign({}, this._diffDrafts);
if (!diffDrafts[draft.path]) {
diffDrafts[draft.path] = [draft];
this._diffDrafts = diffDrafts;
return;
}
for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
if (this._diffDrafts[draft.path][i].id === draft.id) {
diffDrafts[draft.path][i] = draft;
this._diffDrafts = diffDrafts;
return;
}
}
diffDrafts[draft.path].push(draft);
diffDrafts[draft.path].sort((c1, c2) =>
// No line number means that it’s a file comment. Sort it above the
// others.
(c1.line || -1) - (c2.line || -1)
);
this._diffDrafts = diffDrafts;
}
_handleCommentDiscard(e) {
const draft = e.detail.comment;
if (!draft.__draft) { return; }
if (!this._diffDrafts[draft.path]) {
return;
}
let index = -1;
for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
if (this._diffDrafts[draft.path][i].id === draft.id) {
index = i;
break;
}
}
if (index === -1) {
// It may be a draft that hasn’t been added to _diffDrafts since it was
// never saved.
return;
}
draft.patch_set = draft.patch_set || this._patchRange.patchNum;
// The use of path-based notification helpers (set, push) can’t be used
// because the paths could contain dots in them. A new object must be
// created to satisfy Polymer’s dirty checking.
// https://github.com/Polymer/polymer/issues/3127
const diffDrafts = Object.assign({}, this._diffDrafts);
diffDrafts[draft.path].splice(index, 1);
if (diffDrafts[draft.path].length === 0) {
delete diffDrafts[draft.path];
}
this._diffDrafts = diffDrafts;
}
_handleReplyTap(e) {
e.preventDefault();
this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
}
_handleReadyTap(e) {
e.preventDefault();
const button = e && e.target;
if (button) {
button.loading = true;
}
return this.$.restAPI.startReview(this._changeNum)
.then(result => {
this._reload(result);
})
.finally(() => {
if (button) {
button.loading = false;
}
});
}
_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() {
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);
if (this._changeNum !== value.changeNum) {
this._initialLoadComplete = false;
}
const patchRange = {
patchNum: value.patchNum,
basePatchNum: value.basePatchNum || 'PARENT',
};
this.$.fileList.collapseAllDiffs();
this._patchRange = patchRange;
// If the change has already been loaded and the parameter change is only
// in the patch range, then don't do a full reload.
if (this._initialLoadComplete && patchChanged) {
if (patchRange.patchNum == null) {
patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
}
this._reloadPatchNumDependentResources().then(() => {
this._sendShowChangeEvent();
});
return;
}
this._changeNum = value.changeNum;
this.$.relatedChanges.clear();
this._reload(true).then(() => {
this._performPostLoadTasks();
});
pluginLoader.awaitPluginsLoaded().then(() => {
this._initActiveTabs(value);
});
}
_initActiveTabs(params = {}) {
let primaryTab = PrimaryTabs.FILES;
if (params.queryMap && params.queryMap.has('tab')) {
primaryTab = params.queryMap.get('tab');
}
this._setActivePrimaryTab({
detail: {
tab: primaryTab,
},
});
// TODO: should drop this once we move CommentThreads tab
// to primary as well
let secondaryTab = SecondaryTabs.CHANGE_LOG;
if (params.queryMap && params.queryMap.has('secondaryTab')) {
secondaryTab = params.queryMap.get('secondaryTab');
}
this._setActiveSecondaryTab({
detail: {
tab: secondaryTab,
},
});
}
_sendShowChangeEvent() {
this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
change: this._change,
patchNum: this._patchRange.patchNum,
info: {mergeable: this._mergeable},
});
}
_performPostLoadTasks() {
this._maybeShowReplyDialog();
this._maybeShowRevertDialog();
this._sendShowChangeEvent();
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].some(arg => arg === 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.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() {
pluginLoader.awaitPluginsLoaded()
.then(this._getLoggedIn.bind(this))
.then(loggedIn => {
if (!loggedIn || !this._change ||
this._change.status !== this.ChangeStatus.MERGED) {
// Do not display dialog if not logged-in or the change is not
// merged.
return;
}
if (this._getUrlParameter('revert')) {
this.$.actions.showRevertDialog();
}
});
}
_maybeShowReplyDialog() {
this._getLoggedIn().then(loggedIn => {
if (!loggedIn) { return; }
if (this.viewState.showReplyDialog) {
this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
// TODO(kaspern@): Find a better signal for when to call center.
this.async(() => { this.$.replyOverlay.center(); }, 100);
this.async(() => { this.$.replyOverlay.center(); }, 1000);
this.set('viewState.showReplyDialog', false);
}
});
}
_resetFileListViewState() {
this.set('viewState.selectedFileIndex', 0);
this.set('viewState.scrollTop', 0);
if (!!this.viewState.changeNum &&
this.viewState.changeNum !== this._changeNum) {
// Reset the diff mode to null when navigating from one change to
// another, so that the user's preference is restored.
this._setDiffViewMode(true);
this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
}
this.set('viewState.changeNum', this._changeNum);
this.set('viewState.patchRange', this._patchRange);
}
_changeChanged(change) {
if (!change || !this._patchRange || !this._allPatchSets) { return; }
// 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 ||
this.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].some(arg => arg === 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[1];
}
if (changeId) {
// A change-id is detected in the commit message.
if (changeId === change.change_id) {
// The change-id found matches the real change-id.
return null;
}
// The change-id found does not match the change-id.
return CHANGE_ID_ERROR.MISMATCH;
}
// There is no change-id in the commit message.
return CHANGE_ID_ERROR.MISSING;
}
_computeLabelNames(labels) {
return Object.keys(labels).sort();
}
_computeLabelValues(labelName, labels) {
const result = [];
const t = labels[labelName];
if (!t) { return result; }
const approvals = t.all || [];
for (const label of approvals) {
if (label.value && label.value != labels[labelName].default_value) {
let labelClassName;
let labelValPrefix = '';
if (label.value > 0) {
labelValPrefix = '+';
labelClassName = 'approved';
} else if (label.value < 0) {
labelClassName = 'notApproved';
}
result.push({
value: labelValPrefix + label.value,
className: labelClassName,
account: label,
});
}
}
return result;
}
_computeReplyButtonLabel(changeRecord) {
// Polymer 2: check for undefined
if ([changeRecord].some(arg => arg === undefined)) {
return 'Reply';
}
const drafts = (changeRecord && changeRecord.base) || {};
const draftCount = Object.keys(drafts)
.reduce((count, file) => count + drafts[file].length, 0);
let label = 'Reply';
if (draftCount > 0) {
label += ' (' + draftCount + ')';
}
return label;
}
_handleOpenReplyDialog(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) {
return;
}
this._getLoggedIn().then(isLoggedIn => {
if (!isLoggedIn) {
this.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.$.downloadOverlay.open();
}
_handleEditTopic(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.metadata.editTopic();
}
_handleRefreshChange(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
GerritNav.navigateToChange(this._change);
}
_handleToggleChangeStar(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.changeStar.toggleStar();
}
_handleUpToDashboard(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this._determinePageBack();
}
_handleExpandAllMessages(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.messagesList.handleExpandCollapse(true);
}
_handleCollapseAllMessages(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.messagesList.handleExpandCollapse(false);
}
_handleOpenDiffPrefsShortcut(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
if (this._diffPrefsDisabled) { return; }
e.preventDefault();
this.$.fileList.openDiffPrefs();
}
_determinePageBack() {
// Default backPage to root if user came to change view page
// via an email link, etc.
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(this.$.jsAPI.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();
});
}
_handleReloadChange(e) {
return this._reload().then(() => {
// If the change was rebased or submitted, we need to reload the page
// with the latest patch.
const action = e.detail.action;
if (action === 'rebase' || action === 'submit') {
GerritNav.navigateToChange(this._change);
}
});
}
_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: this.EDIT_NAME,
basePatchNum: edit.base_patch_set_number,
commit: edit.commit,
fetch: edit.fetch,
};
// If the edit is based on the most recent patchset, load it by
// default, unless another patch set to load was specified in the URL.
if (!this._patchRange.patchNum &&
change.current_revision === edit.base_revision) {
change.current_revision = edit.commit.commit;
this.set('_patchRange.patchNum', this.EDIT_NAME);
// Because edits are fibbed as revisions and added to the revisions
// array, and revision actions are always derived from the 'latest'
// patch set, we must copy over actions from the patch set base.
// Context: Issue 7243
change.revisions[edit.commit.commit].actions =
change.revisions[edit.base_revision].actions;
}
}
_getChangeDetail() {
const detailCompletes = this.$.restAPI.getChangeDetail(
this._changeNum, this._handleGetChangeDetailError.bind(this));
const editCompletes = this._getEdit();
const prefCompletes = this._getPreferences();
return Promise.all([detailCompletes, editCompletes, prefCompletes])
.then(([change, edit, prefs]) => {
this._prefs = prefs;
if (!change) {
return '';
}
this._processEdit(change, edit);
// Issue 4190: Coalesce missing topics to null.
if (!change.topic) { change.topic = null; }
if (!change.reviewer_updates) {
change.reviewer_updates = null;
}
const latestRevisionSha = this._getLatestRevisionSHA(change);
const currentRevision = change.revisions[latestRevisionSha];
if (currentRevision.commit && currentRevision.commit.message) {
this._latestCommitMessage = this._prepareCommitMsgForLinkify(
currentRevision.commit.message);
} else {
this._latestCommitMessage = null;
}
const lineHeight = getComputedStyle(this).lineHeight;
// Slice returns a number as a string, convert to an int.
this._lineHeight =
parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
this._change = change;
if (!this._patchRange || !this._patchRange.patchNum ||
this.patchNumEquals(this._patchRange.patchNum,
currentRevision._number)) {
// CommitInfo.commit is optional, and may need patching.
if (!currentRevision.commit.commit) {
currentRevision.commit.commit = latestRevisionSha;
}
this._commitInfo = currentRevision.commit;
this._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,
this.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() {
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 = Object.assign({}, this._changeComments.drafts);
this._commentThreads = this._changeComments.getAllThreadsForChange()
.map(c => Object.assign({}, c));
this._draftCommentThreads = this._commentThreads
.filter(c => c.comments[c.comments.length - 1].__draft);
}
/**
* Reload the change.
*
* @param {boolean=} opt_isLocationChange Reloads the related changes
* when true and ends reporting events that started on location change.
* @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) {
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 === this.ChangeStatus.MERGED ||
this._change.status === this.ChangeStatus.ABANDONED) {
this._mergeable = false;
return Promise.resolve();
}
this._mergeable = null;
return this.$.restAPI.getMergeable(this._changeNum).then(m => {
this._mergeable = m.mergeable;
});
}
_computeCanStartReview(change) {
return !!(change.actions && change.actions.ready &&
change.actions.ready.enabled);
}
_computeReplyDisabled() { return false; }
_computeChangePermalinkAriaLabel(changeNum) {
return 'Change ' + changeNum;
}
_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 (!util.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(() => {
this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
let toastMessage = null;
if (!result.isLatest) {
toastMessage = ReloadToastMessage.NEWER_REVISION;
} else if (result.newStatus === this.ChangeStatus.MERGED) {
toastMessage = ReloadToastMessage.MERGED;
} else if (result.newStatus === this.ChangeStatus.ABANDONED) {
toastMessage = ReloadToastMessage.ABANDONED;
} else if (result.newStatus === this.ChangeStatus.NEW) {
toastMessage = ReloadToastMessage.RESTORED;
} else if (result.newMessages) {
toastMessage = ReloadToastMessage.NEW_MESSAGE;
}
if (!toastMessage) {
this._startUpdateCheckTimer();
return;
}
this._cancelUpdateCheckTimer();
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {
message: toastMessage,
// Persist this alert.
dismissOnNavigation: true,
action: 'Reload',
callback: function() {
// Load the current change without any patch range.
GerritNav.navigateToChange(this._change);
}.bind(this),
},
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].some(arg => arg === undefined)) {
return undefined;
}
if (paramsRecord.base && paramsRecord.base.edit) { return true; }
const patchRange = patchRangeRecord.base || {};
return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
}
_handleFileActionTap(e) {
e.preventDefault();
const controls = this.$.fileListHeader.$.editControls;
const path = e.detail.path;
switch (e.detail.action) {
case GrEditConstants.Actions.DELETE.id:
controls.openDeleteDialog(path);
break;
case GrEditConstants.Actions.OPEN.id:
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 === this.EDIT_NAME);
if (editInfo) {
GerritNav.navigateToChange(this._change, this.EDIT_NAME);
return;
}
// Avoid putting patch set in the URL unless a non-latest patch set is
// selected.
let patchNum;
if (!this.patchNumEquals(this._patchRange.patchNum,
this.computeLatestPatchNum(this._allPatchSets))) {
patchNum = this._patchRange.patchNum;
}
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;
}
}
customElements.define(GrChangeView.is, GrChangeView);