blob: bd0ef38c136c6f809a8f4342cfa194ec3204a01f [file] [log] [blame]
// Copyright (C) 2016 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
(function() {
'use strict';
const CHANGE_ID_ERROR = {
MISMATCH: 'mismatch',
MISSING: 'missing',
};
const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
const DEFAULT_NUM_FILES_SHOWN = 200;
// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
const REVIEWERS_REGEX = /^(R|CC)=/gm;
const MIN_CHECK_INTERVAL_SECS = 0;
// These are the same as the breakpoint set in CSS. Make sure both are changed
// together.
const BREAKPOINT_RELATED_SMALL = '50em';
const BREAKPOINT_RELATED_MED = '60em';
// In the event that the related changes medium width calculation is too close
// to zero, provide some height.
const MINIMUM_RELATED_MAX_HEIGHT = 100;
const SMALL_RELATED_HEIGHT = 400;
Polymer({
is: 'gr-change-view',
/**
* Fired when the title of the page should change.
*
* @event title-change
*/
/**
* Fired if an error occurs when fetching the change data.
*
* @event page-error
*/
/**
* Fired if being logged in is required.
*
* @event show-auth-required
*/
properties: {
/**
* URL params passed from the router.
*/
params: {
type: Object,
observer: '_paramsChanged',
},
/** @type {?} */
viewState: {
type: Object,
notify: true,
value() { return {}; },
observer: '_viewStateChanged',
},
backPage: String,
hasParent: Boolean,
keyEventTarget: {
type: Object,
value() { return document.body; },
},
/** @type {?} */
_serverConfig: {
type: Object,
observer: '_startUpdateCheckTimer',
},
_diffPrefs: Object,
_numFilesShown: {
type: Number,
value: DEFAULT_NUM_FILES_SHOWN,
observer: '_numFilesShownChanged',
},
_account: {
type: Object,
value: {},
},
_canStartReview: {
type: Boolean,
computed: '_computeCanStartReview(_loggedIn, _change, _account)',
},
_comments: Object,
/** @type {?} */
_change: {
type: Object,
observer: '_changeChanged',
},
/** @type {?} */
_commitInfo: Object,
_files: Object,
_changeNum: String,
_diffDrafts: {
type: Object,
value() { return {}; },
},
_editingCommitMessage: {
type: Boolean,
value: false,
},
_hideEditCommitMessage: {
type: Boolean,
computed: '_computeHideEditCommitMessage(_loggedIn, ' +
'_editingCommitMessage, _change)',
},
/** @type {?string} */
_latestCommitMessage: {
type: String,
value: '',
},
_lineHeight: Number,
_changeIdCommitMessageError: {
type: String,
computed:
'_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
},
/** @type {?} */
_patchRange: {
type: Object,
observer: '_updateSelected',
},
_relatedChangesLoading: {
type: Boolean,
value: true,
},
_currentRevisionActions: Object,
_allPatchSets: {
type: Array,
computed: 'computeAllPatchSets(_change, _change.revisions.*)',
},
_loggedIn: {
type: Boolean,
value: false,
},
_loading: Boolean,
/** @type {?} */
_projectConfig: Object,
_rebaseOnCurrent: Boolean,
_replyButtonLabel: {
type: String,
value: 'Reply',
computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
},
_selectedPatchSet: String,
_initialLoadComplete: {
type: Boolean,
value: false,
},
_descriptionReadOnly: {
type: Boolean,
computed: '_computeDescriptionReadOnly(_loggedIn, _change, _account)',
},
_replyDisabled: {
type: Boolean,
value: true,
computed: '_computeReplyDisabled(_serverConfig)',
},
_changeStatus: {
type: String,
computed: 'changeStatusString(_change)',
},
_commitCollapsed: {
type: Boolean,
value: true,
},
_relatedChangesCollapsed: {
type: Boolean,
value: true,
},
/** @type {?number} */
_updateCheckTimerHandle: Number,
_sortedRevisions: Array,
_editLoaded: {
type: Boolean,
computed: '_computeEditLoaded(_patchRange.*)',
},
},
behaviors: [
Gerrit.KeyboardShortcutBehavior,
Gerrit.PatchSetBehavior,
Gerrit.RESTClientBehavior,
],
listeners: {
'topic-changed': '_handleTopicChanged',
},
observers: [
'_labelsChanged(_change.labels.*)',
'_paramsAndChangeChanged(params, _change)',
'_updateSortedRevisions(_change.revisions.*)',
],
keyBindings: {
'shift+r': '_handleCapitalRKey',
'a': '_handleAKey',
'd': '_handleDKey',
's': '_handleSKey',
'u': '_handleUKey',
'x': '_handleXKey',
'z': '_handleZKey',
',': '_handleCommaKey',
},
attached() {
this._getServerConfig().then(config => {
this._serverConfig = config;
});
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
if (loggedIn) {
this.$.restAPI.getAccount().then(acct => {
this._account = acct;
});
}
});
this.addEventListener('comment-save', this._handleCommentSave.bind(this));
this.addEventListener('comment-refresh', this._getDiffDrafts.bind(this));
this.addEventListener('comment-discard',
this._handleCommentDiscard.bind(this));
this.addEventListener('editable-content-save',
this._handleCommitMessageSave.bind(this));
this.addEventListener('editable-content-cancel',
this._handleCommitMessageCancel.bind(this));
this.listen(window, 'scroll', '_handleScroll');
this.listen(document, 'visibilitychange', '_handleVisibilityChange');
},
detached() {
this.unlisten(window, 'scroll', '_handleScroll');
this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
if (this._updateCheckTimerHandle) {
this._cancelUpdateCheckTimer();
}
},
_updateSortedRevisions(revisionsRecord) {
const revisions = revisionsRecord.base;
this._sortedRevisions = this.sortRevisions(Object.values(revisions));
},
_computePrefsButtonHidden(prefs, loggedIn) {
return !loggedIn || !prefs;
},
_handleEditCommitMessage(e) {
this._editingCommitMessage = true;
this.$.commitMessageEditor.focusTextarea();
},
_handleCommitMessageSave(e) {
const message = e.detail.content;
this.$.jsAPI.handleCommitMessage(this._change, message);
this.$.commitMessageEditor.disabled = true;
this.$.restAPI.putChangeCommitMessage(
this._changeNum, message).then(resp => {
this.$.commitMessageEditor.disabled = false;
if (!resp.ok) { return; }
this._latestCommitMessage = this._prepareCommitMsgForLinkify(
message);
this._editingCommitMessage = false;
this._reloadWindow();
}).catch(err => {
this.$.commitMessageEditor.disabled = false;
});
},
_reloadWindow() {
window.location.reload();
},
_handleCommitMessageCancel(e) {
this._editingCommitMessage = false;
},
_computeHideEditCommitMessage(loggedIn, editing, change) {
if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
return true;
}
return false;
},
_handlePrefsTap(e) {
e.preventDefault();
this.$.fileList.openDiffPrefs();
},
_handleCommentSave(e) {
if (!e.target.comment.__draft) { return; }
const draft = e.target.comment;
draft.patch_set = draft.patch_set || this._patchRange.patchNum;
// The use of path-based notification helpers (set, push) can’t be used
// because the paths could contain dots in them. A new object must be
// created to satisfy Polymer’s dirty checking.
// https://github.com/Polymer/polymer/issues/3127
const diffDrafts = Object.assign({}, this._diffDrafts);
if (!diffDrafts[draft.path]) {
diffDrafts[draft.path] = [draft];
this._diffDrafts = diffDrafts;
return;
}
for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
if (this._diffDrafts[draft.path][i].id === draft.id) {
diffDrafts[draft.path][i] = draft;
this._diffDrafts = diffDrafts;
return;
}
}
diffDrafts[draft.path].push(draft);
diffDrafts[draft.path].sort((c1, c2) => {
// No line number means that it’s a file comment. Sort it above the
// others.
return (c1.line || -1) - (c2.line || -1);
});
this._diffDrafts = diffDrafts;
},
_handleCommentDiscard(e) {
if (!e.target.comment.__draft) { return; }
const draft = e.target.comment;
if (!this._diffDrafts[draft.path]) {
return;
}
let index = -1;
for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
if (this._diffDrafts[draft.path][i].id === draft.id) {
index = i;
break;
}
}
if (index === -1) {
// It may be a draft that hasn’t been added to _diffDrafts since it was
// never saved.
return;
}
draft.patch_set = draft.patch_set || this._patchRange.patchNum;
// The use of path-based notification helpers (set, push) can’t be used
// because the paths could contain dots in them. A new object must be
// created to satisfy Polymer’s dirty checking.
// https://github.com/Polymer/polymer/issues/3127
const diffDrafts = Object.assign({}, this._diffDrafts);
diffDrafts[draft.path].splice(index, 1);
if (diffDrafts[draft.path].length === 0) {
delete diffDrafts[draft.path];
}
this._diffDrafts = diffDrafts;
},
_handlePatchChange(e) {
this._changePatchNum(e.target.value, true);
},
_handleReplyTap(e) {
e.preventDefault();
this._openReplyDialog();
},
_handleDownloadTap(e) {
e.preventDefault();
this.$.downloadOverlay.open().then(() => {
this.$.downloadOverlay
.setFocusStops(this.$.downloadDialog.getFocusStops());
this.$.downloadDialog.focus();
});
},
_handleDownloadDialogClose(e) {
this.$.downloadOverlay.close();
},
_handleMessageReply(e) {
const msg = e.detail.message.message;
const quoteStr = msg.split('\n').map(
line => { return '> ' + line; }).join('\n') + '\n\n';
if (quoteStr !== this.$.replyDialog.quote) {
this.$.replyDialog.draft = quoteStr;
}
this.$.replyDialog.quote = quoteStr;
this._openReplyDialog();
},
_handleReplyOverlayOpen(e) {
// This is needed so that focus is not set on the reply overlay
// when the suggestion overaly from gr-autogrow-textarea opens.
if (e.target === this.$.replyOverlay) {
this.$.replyDialog.focus();
}
},
_handleReplySent(e) {
this.$.replyOverlay.close();
this._reload();
},
_handleReplyCancel(e) {
this.$.replyOverlay.close();
},
_handleReplyAutogrow(e) {
this.$.replyOverlay.refit();
},
_handleShowReplyDialog(e) {
let target = this.$.replyDialog.FocusTarget.REVIEWERS;
if (e.detail.value && e.detail.value.ccsOnly) {
target = this.$.replyDialog.FocusTarget.CCS;
}
this._openReplyDialog(target);
},
_handleScroll() {
this.debounce('scroll', () => {
this.viewState.scrollTop = document.body.scrollTop;
}, 150);
},
_paramsChanged(value) {
if (value.view !== Gerrit.Nav.View.CHANGE) {
this._initialLoadComplete = false;
return;
}
const patchChanged = this._patchRange &&
(value.patchNum !== undefined && value.basePatchNum !== undefined) &&
(this._patchRange.patchNum !== value.patchNum ||
this._patchRange.basePatchNum !== value.basePatchNum);
if (this._changeNum !== value.changeNum) {
this._initialLoadComplete = false;
}
const patchRange = {
patchNum: value.patchNum,
basePatchNum: value.basePatchNum || 'PARENT',
};
if (this._initialLoadComplete && patchChanged) {
if (patchRange.patchNum == null) {
patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
}
this._patchRange = patchRange;
this._reloadPatchNumDependentResources().then(() => {
this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
change: this._change,
patchNum: patchRange.patchNum,
});
});
return;
}
this._changeNum = value.changeNum;
this._patchRange = patchRange;
this.$.relatedChanges.clear();
this._reload().then(() => {
this._performPostLoadTasks();
});
},
_performPostLoadTasks() {
this.$.relatedChanges.reload();
this._maybeShowReplyDialog();
this._maybeShowRevertDialog();
this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
change: this._change,
patchNum: this._patchRange.patchNum,
});
this.async(() => {
if (this.viewState.scrollTop) {
document.documentElement.scrollTop =
document.body.scrollTop = this.viewState.scrollTop;
} else {
this._maybeScrollToMessage(window.location.hash);
}
this._initialLoadComplete = true;
});
},
_paramsAndChangeChanged(value) {
// If the change number or patch range is different, then reset the
// selected file index.
const patchRangeState = this.viewState.patchRange;
if (this.viewState.changeNum !== this._changeNum ||
patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
patchRangeState.patchNum !== this._patchRange.patchNum) {
this._resetFileListViewState();
}
},
_viewStateChanged(viewState) {
this._numFilesShown = viewState.numFilesShown ?
viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
},
_numFilesShownChanged(numFilesShown) {
this.viewState.numFilesShown = numFilesShown;
},
_maybeScrollToMessage(hash) {
const msgPrefix = '#message-';
if (hash.startsWith(msgPrefix)) {
this.$.messageList.scrollToMessage(hash.substr(msgPrefix.length));
}
},
_getLocationSearch() {
// Not inlining to make it easier to test.
return window.location.search;
},
_getUrlParameter(param) {
const pageURL = this._getLocationSearch().substring(1);
const vars = pageURL.split('&');
for (let i = 0; i < vars.length; i++) {
const name = vars[i].split('=');
if (name[0] == param) {
return name[0];
}
}
return null;
},
_maybeShowRevertDialog() {
Gerrit.awaitPluginsLoaded()
.then(this._getLoggedIn.bind(this))
.then(loggedIn => {
if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
// Do not display dialog if not logged-in or the change is not
// merged.
return;
}
if (this._getUrlParameter('revert')) {
this.$.actions.showRevertDialog();
}
});
},
_maybeShowReplyDialog() {
this._getLoggedIn().then(loggedIn => {
if (!loggedIn) { return; }
if (this.viewState.showReplyDialog) {
this._openReplyDialog();
// TODO(kaspern@): Find a better signal for when to call center.
this.async(() => { this.$.replyOverlay.center(); }, 100);
this.async(() => { this.$.replyOverlay.center(); }, 1000);
this.set('viewState.showReplyDialog', false);
}
});
},
_resetFileListViewState() {
this.set('viewState.selectedFileIndex', 0);
this.set('viewState.scrollTop', 0);
if (!!this.viewState.changeNum &&
this.viewState.changeNum !== this._changeNum) {
// Reset the diff mode to null when navigating from one change to
// another, so that the user's preference is restored.
this.set('viewState.diffMode', null);
this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
}
this.set('viewState.changeNum', this._changeNum);
this.set('viewState.patchRange', this._patchRange);
},
_changeChanged(change) {
if (!change || !this._patchRange || !this._allPatchSets) { return; }
this.set('_patchRange.basePatchNum',
this._patchRange.basePatchNum || 'PARENT');
this.set('_patchRange.patchNum',
this._patchRange.patchNum ||
this.computeLatestPatchNum(this._allPatchSets));
this._updateSelected();
const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
this.fire('title-change', {title});
},
/**
* Change active patch to the provided patch num.
* @param {number|string} patchNum the patchn number to be viewed.
* @param {boolean} opt_forceParams When set to true, the resulting URL will
* always include the patch range, even if the requested patchNum is
* known to be the latest.
*/
_changePatchNum(patchNum, opt_forceParams) {
if (!opt_forceParams) {
let currentPatchNum;
if (this._change.current_revision) {
currentPatchNum =
this._change.revisions[this._change.current_revision]._number;
} else {
currentPatchNum = this.computeLatestPatchNum(this._allPatchSets);
}
if (this.patchNumEquals(patchNum, currentPatchNum) &&
this._patchRange.basePatchNum === 'PARENT') {
Gerrit.Nav.navigateToChange(this._change);
return;
}
}
Gerrit.Nav.navigateToChange(this._change, patchNum,
this._patchRange.basePatchNum);
},
_computeChangeUrl(change) {
return Gerrit.Nav.getUrlForChange(change);
},
_computeShowCommitInfo(changeStatus, current_revision) {
return changeStatus === 'Merged' && current_revision;
},
_computeMergedCommitInfo(current_revision, revisions) {
const rev = revisions[current_revision];
if (!rev || !rev.commit) { return {}; }
// CommitInfo.commit is optional. Set commit in all cases to avoid error
// in <gr-commit-info>. @see Issue 5337
if (!rev.commit.commit) { rev.commit.commit = current_revision; }
return rev.commit;
},
_computeChangeIdClass(displayChangeId) {
return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
},
_computeTitleAttributeWarning(displayChangeId) {
if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
return 'Change-Id mismatch';
} else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
return 'No Change-Id in commit message';
}
},
_computeChangeIdCommitMessageError(commitMessage, change) {
if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
// Find the last match in the commit message:
let changeId;
let changeIdArr;
while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
changeId = changeIdArr[1];
}
if (changeId) {
// A change-id is detected in the commit message.
if (changeId === change.change_id) {
// The change-id found matches the real change-id.
return null;
}
// The change-id found does not match the change-id.
return CHANGE_ID_ERROR.MISMATCH;
}
// There is no change-id in the commit message.
return CHANGE_ID_ERROR.MISSING;
},
_computePatchInfoClass(patchNum, allPatchSets) {
const latestNum = this.computeLatestPatchNum(allPatchSets);
if (this.patchNumEquals(patchNum, latestNum)) {
return '';
}
return 'patchInfo--oldPatchSet';
},
/**
* Determines if a patch number should be disabled based on value of the
* basePatchNum from gr-file-list.
* @param {number} patchNum Patch number available in dropdown
* @param {number|string} basePatchNum Base patch number from file list
* @return {boolean}
*/
_computePatchSetDisabled(patchNum, basePatchNum) {
if (basePatchNum === 'PARENT') { return false; }
return this.findSortedIndex(patchNum, this._sortedRevisions) <=
this.findSortedIndex(basePatchNum, this._sortedRevisions);
},
_computeLabelNames(labels) {
return Object.keys(labels).sort();
},
_computeLabelValues(labelName, labels) {
const result = [];
const t = labels[labelName];
if (!t) { return result; }
const approvals = t.all || [];
for (const label of approvals) {
if (label.value && label.value != labels[labelName].default_value) {
let labelClassName;
let labelValPrefix = '';
if (label.value > 0) {
labelValPrefix = '+';
labelClassName = 'approved';
} else if (label.value < 0) {
labelClassName = 'notApproved';
}
result.push({
value: labelValPrefix + label.value,
className: labelClassName,
account: label,
});
}
}
return result;
},
_computeReplyButtonLabel(changeRecord, canStartReview) {
if (canStartReview) {
return 'Start review';
}
const drafts = (changeRecord && changeRecord.base) || {};
const draftCount = Object.keys(drafts).reduce((count, file) => {
return count + drafts[file].length;
}, 0);
let label = 'Reply';
if (draftCount > 0) {
label += ' (' + draftCount + ')';
}
return label;
},
_handleAKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) {
return;
}
this._getLoggedIn().then(isLoggedIn => {
if (!isLoggedIn) {
this.fire('show-auth-required');
return;
}
e.preventDefault();
this._openReplyDialog();
});
},
_handleDKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.downloadOverlay.open();
},
_handleCapitalRKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
Gerrit.Nav.navigateToChange(this._change);
},
_handleSKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.changeStar.toggleStar();
},
_handleUKey(e) {
if (this.shouldSuppressKeyboardShortcut(e)) { return; }
e.preventDefault();
this._determinePageBack();
},
_handleXKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.messageList.handleExpandCollapse(true);
},
_handleZKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.messageList.handleExpandCollapse(false);
},
_handleCommaKey(e) {
if (this.shouldSuppressKeyboardShortcut(e) ||
this.modifierPressed(e)) { return; }
e.preventDefault();
this.$.fileList.openDiffPrefs();
},
_determinePageBack() {
// Default backPage to '/' if user came to change view page
// via an email link, etc.
Gerrit.Nav.navigateToRelativeUrl(this.backPage || '/');
},
_handleLabelRemoved(splices, path) {
for (const splice of splices) {
for (const removed of splice.removed) {
const changePath = path.split('.');
const labelPath = changePath.splice(0, changePath.length - 2);
const labelDict = this.get(labelPath);
if (labelDict.approved &&
labelDict.approved._account_id === removed._account_id) {
this._reload();
return;
}
}
}
},
_labelsChanged(changeRecord) {
if (!changeRecord) { return; }
if (changeRecord.value && changeRecord.value.indexSplices) {
this._handleLabelRemoved(changeRecord.value.indexSplices,
changeRecord.path);
}
this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
change: this._change,
});
},
/**
* @param {string=} opt_section
*/
_openReplyDialog(opt_section) {
this.$.replyOverlay.open().then(() => {
this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
this.$.replyDialog.open(opt_section);
Polymer.dom.flush();
this.$.replyOverlay.center();
});
},
_handleReloadChange(e) {
return this._reload().then(() => {
// If the change was rebased, we need to reload the page with the
// latest patch.
if (e.detail.action === 'rebase') {
Gerrit.Nav.navigateToChange(this._change);
}
});
},
_handleGetChangeDetailError(response) {
this.fire('page-error', {response});
},
_getDiffDrafts() {
return this.$.restAPI.getDiffDrafts(this._changeNum).then(drafts => {
this._diffDrafts = drafts;
});
},
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
},
_getServerConfig() {
return this.$.restAPI.getConfig();
},
_getProjectConfig() {
return this.$.restAPI.getProjectConfig(this._change.project).then(
config => {
this._projectConfig = config;
});
},
_updateRebaseAction(revisionActions) {
if (revisionActions && revisionActions.rebase) {
revisionActions.rebase.rebaseOnCurrent =
!!revisionActions.rebase.enabled;
revisionActions.rebase.enabled = true;
}
return revisionActions;
},
_prepareCommitMsgForLinkify(msg) {
// TODO(wyatta) switch linkify sequence, see issue 5526.
// This is a zero-with space. It is added to prevent the linkify library
// from including R= or CC= as part of the email address.
return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
},
/**
* Utility function to make the necessary modifications to a change in the
* case an edit exists.
*
* @param {!Object} change
* @param {?Object} edit
*/
_processEdit(change, edit) {
if (!edit) { return; }
change.revisions[edit.commit.commit] = {
_number: this.EDIT_NAME,
basePatchNum: edit.base_patch_set_number,
commit: edit.commit,
fetch: edit.fetch,
};
// If the edit is based on the most recent patchset, load it by
// default, unless another patch set to load was specified in the URL.
if (!this._patchRange.patchNum &&
change.current_revision === edit.base_revision) {
change.current_revision = edit.commit.commit;
this._patchRange.patchNum = this.EDIT_NAME;
}
},
_getChangeDetail() {
const detailCompletes = this.$.restAPI.getChangeDetail(
this._changeNum, this._handleGetChangeDetailError.bind(this));
const editCompletes = this._getEdit();
return Promise.all([detailCompletes, editCompletes])
.then(([change, edit]) => {
if (!change) {
return '';
}
this._processEdit(change, edit);
// Issue 4190: Coalesce missing topics to null.
if (!change.topic) { change.topic = null; }
if (!change.reviewer_updates) {
change.reviewer_updates = null;
}
const latestRevisionSha = this._getLatestRevisionSHA(change);
const currentRevision = change.revisions[latestRevisionSha];
if (currentRevision.commit && currentRevision.commit.message) {
this._latestCommitMessage = this._prepareCommitMsgForLinkify(
currentRevision.commit.message);
} else {
this._latestCommitMessage = null;
}
const lineHeight = getComputedStyle(this).lineHeight;
// Slice returns a number as a string, convert to an int.
this._lineHeight =
parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
this._change = change;
if (!this._patchRange || !this._patchRange.patchNum ||
this.patchNumEquals(this._patchRange.patchNum,
currentRevision._number)) {
// CommitInfo.commit is optional, and may need patching.
if (!currentRevision.commit.commit) {
currentRevision.commit.commit = latestRevisionSha;
}
this._commitInfo = currentRevision.commit;
this._currentRevisionActions =
this._updateRebaseAction(currentRevision.actions);
// TODO: Fetch and process files.
}
});
},
_getComments() {
return this.$.restAPI.getDiffComments(this._changeNum).then(comments => {
this._comments = comments;
});
},
_getEdit() {
return this.$.restAPI.getChangeEdit(this._changeNum, true);
},
_getLatestCommitMessage() {
return this.$.restAPI.getChangeCommitInfo(this._changeNum,
this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
this._latestCommitMessage =
this._prepareCommitMsgForLinkify(commitInfo.message);
});
},
_getLatestRevisionSHA(change) {
if (change.current_revision) {
return change.current_revision;
}
// current_revision may not be present in the case where the latest rev is
// a draft and the user doesn’t have permission to view that rev.
let latestRev = null;
let latestPatchNum = -1;
for (const rev in change.revisions) {
if (!change.revisions.hasOwnProperty(rev)) { continue; }
if (change.revisions[rev]._number > latestPatchNum) {
latestRev = rev;
latestPatchNum = change.revisions[rev]._number;
}
}
return latestRev;
},
_getCommitInfo() {
return this.$.restAPI.getChangeCommitInfo(
this._changeNum, this._patchRange.patchNum).then(
commitInfo => {
this._commitInfo = commitInfo;
});
},
_reloadDiffDrafts() {
this._diffDrafts = {};
this._getDiffDrafts().then(() => {
if (this.$.replyOverlay.opened) {
this.async(() => { this.$.replyOverlay.center(); }, 1);
}
});
},
_reload() {
this._loading = true;
this._relatedChangesCollapsed = true;
this._getLoggedIn().then(loggedIn => {
if (!loggedIn) { return; }
this._reloadDiffDrafts();
});
const detailCompletes = this._getChangeDetail().then(() => {
this._loading = false;
this._getProjectConfig();
});
this._getComments();
if (this._patchRange.patchNum) {
return Promise.all([
this._reloadPatchNumDependentResources(),
detailCompletes,
]).then(() => {
return this.$.actions.reload();
});
} else {
// The patch number is reliant on the change detail request.
return detailCompletes.then(() => {
this.$.fileList.reload();
if (!this._latestCommitMessage) {
this._getLatestCommitMessage();
}
});
}
},
/**
* Kicks off requests for resources that rely on the patch range
* (`this._patchRange`) being defined.
*/
_reloadPatchNumDependentResources() {
return Promise.all([
this._getCommitInfo(),
this.$.fileList.reload(),
]);
},
_updateSelected() {
this._selectedPatchSet = this._patchRange.patchNum;
},
_computePatchSetDescription(change, patchNum) {
const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
return (rev && rev.description) ?
rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
},
_computePatchSetCommentsString(allComments, patchNum) {
let numComments = 0;
let numUnresolved = 0;
for (const file in allComments) {
if (allComments.hasOwnProperty(file)) {
numComments += this.$.fileList.getCommentsForPath(
allComments, patchNum, file).length;
numUnresolved += this.$.fileList.computeUnresolvedNum(
allComments, {}, patchNum, file);
}
}
let commentsStr = '';
if (numComments > 0) {
commentsStr = '(' + numComments + ' comments';
if (numUnresolved > 0) {
commentsStr += ', ' + numUnresolved + ' unresolved';
}
commentsStr += ')';
}
return commentsStr;
},
_computeDescriptionPlaceholder(readOnly) {
return (readOnly ? 'No' : 'Add a') + ' patch set description';
},
_handleDescriptionChanged(e) {
const desc = e.detail.trim();
const rev = this.getRevisionByPatchNum(this._change.revisions,
this._selectedPatchSet);
const sha = this._getPatchsetHash(this._change.revisions, rev);
this.$.restAPI.setDescription(this._changeNum,
this._selectedPatchSet, desc)
.then(res => {
if (res.ok) {
this.set(['_change', 'revisions', sha, 'description'], desc);
}
});
},
/**
* @param {!Object} revisions The revisions object keyed by revision hashes
* @param {?Object} patchSet A revision already fetched from {revisions}
* @return {string|undefined} the SHA hash corresponding to the revision.
*/
_getPatchsetHash(revisions, patchSet) {
for (const rev in revisions) {
if (revisions.hasOwnProperty(rev) &&
revisions[rev] === patchSet) {
return rev;
}
}
},
_computeCanStartReview(loggedIn, change, account) {
return !!(loggedIn && change.work_in_progress &&
change.owner._account_id === account._account_id);
},
_computeDescriptionReadOnly(loggedIn, change, account) {
return !(loggedIn && (account._account_id === change.owner._account_id));
},
_computeReplyDisabled() { return false; },
_computeChangePermalinkAriaLabel(changeNum) {
return 'Change ' + changeNum;
},
_computeCommitClass(collapsed, commitMessage) {
if (this._computeCommitToggleHidden(commitMessage)) { return ''; }
return collapsed ? 'collapsed' : '';
},
_computeRelatedChangesClass(collapsed, loading) {
// TODO(beckysiegel) figure out how to check for customstyle in Polymer2,
// since customStyle was removed.
if (!loading && !this.customStyle['--relation-chain-max-height']) {
this._updateRelatedChangeMaxHeight();
}
return collapsed ? 'collapsed' : '';
},
_computeCollapseText(collapsed) {
// Symbols are up and down triangles.
return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
},
_toggleCommitCollapsed() {
this._commitCollapsed = !this._commitCollapsed;
if (this._commitCollapsed) {
window.scrollTo(0, 0);
}
},
_toggleRelatedChangesCollapsed() {
this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
if (this._relatedChangesCollapsed) {
window.scrollTo(0, 0);
}
},
_computeCommitToggleHidden(commitMessage) {
if (!commitMessage) { return true; }
return commitMessage.split('\n').length < MIN_LINES_FOR_COMMIT_COLLAPSE;
},
_getOffsetHeight(element) {
return element.offsetHeight;
},
_getScrollHeight(element) {
return element.scrollHeight;
},
/**
* Get the line height of an element to the nearest integer.
*/
_getLineHeight(element) {
const lineHeightStr = getComputedStyle(element).lineHeight;
return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
},
/**
* New max height for the related changes section, shorter than the existing
* change info height.
*/
_updateRelatedChangeMaxHeight() {
// Takes into account approximate height for the expand button and
// bottom margin.
const EXTRA_HEIGHT = 30;
let newHeight;
const hasCommitToggle =
!this._computeCommitToggleHidden(this._latestCommitMessage);
if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
.matches) {
// In a small (mobile) view, give the relation chain some space.
newHeight = SMALL_RELATED_HEIGHT;
} else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
.matches) {
// Since related changes are below the commit message, but still next to
// metadata, the height should be the height of the metadata minus the
// height of the commit message to reduce jank. However, if that doesn't
// result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
// Note: extraHeight is to take into account margin/padding.
const medRelatedHeight = Math.max(
this._getOffsetHeight(this.$.mainChangeInfo) -
this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
MINIMUM_RELATED_MAX_HEIGHT);
newHeight = medRelatedHeight;
} else {
if (hasCommitToggle) {
// Make sure the content is lined up if both areas have buttons. If
// the commit message is not collapsed, instead use the change info
// height.
newHeight = this._getOffsetHeight(this.$.commitMessage);
} else {
newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
EXTRA_HEIGHT;
}
}
const stylesToUpdate = {};
// Get the line height of related changes, and convert it to the nearest
// integer.
const lineHeight = this._getLineHeight(this.$.relatedChanges);
// Figure out a new height that is divisible by the rounded line height.
const remainder = newHeight % lineHeight;
newHeight = newHeight - remainder;
stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
// Update the max-height of the relation chain to this new height.
if (hasCommitToggle) {
stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
}
this.updateStyles(stylesToUpdate);
},
_computeRelatedChangesToggleClass() {
// Prevents showMore from showing when click on related change, since the
// line height would be positive, but related changes height is 0.
if (!this._getScrollHeight(this.$.relatedChanges)) { return ''; }
return this._getScrollHeight(this.$.relatedChanges) >
(this._getOffsetHeight(this.$.relatedChanges) +
this._getLineHeight(this.$.relatedChanges)) ? 'showToggle' : '';
},
_startUpdateCheckTimer() {
if (!this._serverConfig ||
!this._serverConfig.change ||
this._serverConfig.change.update_delay === undefined ||
this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
return;
}
this._updateCheckTimerHandle = this.async(() => {
this.fetchIsLatestKnown(this._change, this.$.restAPI)
.then(latest => {
if (latest) {
this._startUpdateCheckTimer();
} else {
this._cancelUpdateCheckTimer();
this.fire('show-alert', {
message: 'A newer patch set has been uploaded.',
// Persist this alert.
dismissOnNavigation: true,
action: 'Reload',
callback: function() {
// Load the current change without any patch range.
Gerrit.Nav.navigateToChange(this._change);
}.bind(this),
});
}
});
}, this._serverConfig.change.update_delay * 1000);
},
_cancelUpdateCheckTimer() {
if (this._updateCheckTimerHandle) {
this.cancelAsync(this._updateCheckTimerHandle);
}
this._updateCheckTimerHandle = null;
},
_handleVisibilityChange() {
if (document.hidden && this._updateCheckTimerHandle) {
this._cancelUpdateCheckTimer();
} else if (!this._updateCheckTimerHandle) {
this._startUpdateCheckTimer();
}
},
_handleTopicChanged() {
this.$.relatedChanges.reload();
},
_computeHeaderClass(change) {
return change.work_in_progress ? 'header wip' : 'header';
},
_computeEditLoaded(patchRangeRecord) {
const patchRange = patchRangeRecord.base || {};
return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
},
});
})();