Files
gerrit/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
Ben Rohlfs 7b71b11025 Remove all for...in loops
Change-Id: Ia14fd3730cc94f2b69ad8931b3da397483dea54f
2021-02-12 11:57:26 +01:00

2824 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@polymer/paper-tabs/paper-tabs';
import '../../../styles/shared-styles';
import '../../diff/gr-comment-api/gr-comment-api';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../shared/gr-account-link/gr-account-link';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-change-star/gr-change-star';
import '../../shared/gr-change-status/gr-change-status';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-editable-content/gr-editable-content';
import '../../shared/gr-linked-text/gr-linked-text';
import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-change-actions/gr-change-actions';
import '../gr-change-summary/gr-change-summary';
import '../gr-change-metadata/gr-change-metadata';
import '../../shared/gr-icons/gr-icons';
import '../gr-commit-info/gr-commit-info';
import '../gr-download-dialog/gr-download-dialog';
import '../gr-file-list-header/gr-file-list-header';
import '../gr-included-in-dialog/gr-included-in-dialog';
import '../gr-messages-list/gr-messages-list';
import '../gr-related-changes-list/gr-related-changes-list';
import '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
import '../gr-reply-dialog/gr-reply-dialog';
import '../gr-thread-list/gr-thread-list';
import '../gr-upload-help-dialog/gr-upload-help-dialog';
import '../../checks/gr-checks-tab';
import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-change-view_html';
import {
KeyboardShortcutMixin,
Shortcut,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {GrEditConstants} from '../../edit/gr-edit-constants';
import {pluralize} from '../../../utils/string-util';
import {
getComputedStyleValue,
windowLocationReload,
} from '../../../utils/dom-util';
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
import {DiffViewMode} from '../../../api/diff';
import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
import {appContext} from '../../../services/app-context';
import {ChangeStatus} from '../../../constants/constants';
import {
computeAllPatchSets,
computeLatestPatchNum,
fetchChangeUpdates,
hasEditBasedOnCurrentPatchSet,
hasEditPatchsetLoaded,
PatchSet,
} from '../../../utils/patch-set-util';
import {changeStatuses, changeStatusString} from '../../../utils/change-util';
import {EventType as PluginEventType} from '../../plugins/gr-plugin-types';
import {customElement, property, observe} from '@polymer/decorators';
import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
import {
AccountDetailInfo,
ChangeInfo,
NumericChangeId,
PatchRange,
ActionNameToActionInfoMap,
CommitId,
PatchSetNum,
ParentPatchSetNum,
EditPatchSetNum,
ServerInfo,
ConfigInfo,
PreferencesInfo,
CommitInfo,
RevisionInfo,
EditInfo,
LabelNameToInfoMap,
UrlEncodedCommentId,
QuickLabelInfo,
ApprovalInfo,
ElementPropertyDeepChange,
} from '../../../types/common';
import {DiffPreferencesInfo} from '../../../types/diff';
import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
import {
GrCommentApi,
ChangeComments,
} from '../../diff/gr-comment-api/gr-comment-api';
import {hasOwnProperty} from '../../../utils/common-util';
import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
import {
CommentThread,
UIDraft,
DraftInfo,
isDraftThread,
isRobot,
} from '../../../utils/comment-util';
import {
PolymerDeepPropertyChange,
PolymerSpliceChange,
PolymerSplice,
} from '@polymer/polymer/interfaces';
import {AppElementChangeViewParams} from '../../gr-app-types';
import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
import {
GrFileList,
DEFAULT_NUM_FILES_SHOWN,
} from '../gr-file-list/gr-file-list';
import {
ChangeViewState,
EditRevisionInfo,
isPolymerSpliceChange,
ParsedChangeInfo,
} from '../../../types/types';
import {
CustomKeyboardEvent,
EditableContentSaveEvent,
OpenFixPreviewEvent,
ShowAlertEventDetail,
SwitchTabEvent,
ThreadListModifiedEvent,
} from '../../../types/events';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
import {GrThreadList} from '../gr-thread-list/gr-thread-list';
import {
fireAlert,
fireEvent,
firePageError,
fireDialogChange,
} from '../../../utils/event-util';
import {KnownExperimentId} from '../../../services/flags/flags';
import {fireTitleChange} from '../../../utils/event-util';
import {GerritView} from '../../../services/router/router-model';
import {takeUntil} from 'rxjs/operators';
import {aPluginHasRegistered} from '../../../services/checks/checks-model';
import {Subject} from 'rxjs';
import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
const CHANGE_ID_ERROR = {
MISMATCH: 'mismatch',
MISSING: 'missing',
};
const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
const REVIEWERS_REGEX = /^(R|CC)=/gm;
const MIN_CHECK_INTERVAL_SECS = 0;
// These are the same as the breakpoint set in CSS. Make sure both are changed
// together.
const BREAKPOINT_RELATED_SMALL = '50em';
const BREAKPOINT_RELATED_MED = '75em';
// In the event that the related changes medium width calculation is too close
// to zero, provide some height.
const MINIMUM_RELATED_MAX_HEIGHT = 100;
const SMALL_RELATED_HEIGHT = 400;
const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
const MSG_PREFIX = '#message-';
const ReloadToastMessage = {
NEWER_REVISION: 'A newer patch set has been uploaded',
RESTORED: 'This change has been restored',
ABANDONED: 'This change has been abandoned',
MERGED: 'This change has been merged',
NEW_MESSAGE: 'There are new messages on this change',
};
const 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;
export interface GrChangeView {
$: {
commentAPI: GrCommentApi;
applyFixDialog: GrApplyFixDialog;
fileList: GrFileList & Element;
fileListHeader: GrFileListHeader;
commitMessageEditor: GrEditableContent;
includedInOverlay: GrOverlay;
includedInDialog: GrIncludedInDialog;
downloadOverlay: GrOverlay;
downloadDialog: GrDownloadDialog;
uploadHelpOverlay: GrOverlay;
replyOverlay: GrOverlay;
replyDialog: GrReplyDialog;
mainContent: HTMLDivElement;
changeStar: GrChangeStar;
actions: GrChangeActions;
commitMessage: HTMLDivElement;
commitAndRelated: HTMLDivElement;
metadata: GrChangeMetadata;
mainChangeInfo: HTMLDivElement;
replyBtn: GrButton;
};
}
export type ChangeViewPatchRange = Partial<PatchRange>;
@customElement('gr-change-view')
export class GrChangeView extends KeyboardShortcutMixin(
GestureEventListeners(LegacyElementMixin(PolymerElement))
) {
static get template() {
return htmlTemplate;
}
/**
* Fired when the title of the page should change.
*
* @event title-change
*/
/**
* Fired if an error occurs when fetching the change data.
*
* @event page-error
*/
/**
* Fired if being logged in is required.
*
* @event show-auth-required
*/
reporting = appContext.reportingService;
flagsService = appContext.flagsService;
readonly jsAPI = appContext.jsApiService;
/**
* URL params passed from the router.
*/
@property({type: Object, observer: '_paramsChanged'})
params?: AppElementChangeViewParams;
@property({type: Object, notify: true, observer: '_viewStateChanged'})
viewState: Partial<ChangeViewState> = {};
@property({type: String})
backPage?: string;
@property({type: Boolean})
hasParent?: boolean;
@property({type: Object})
keyEventTarget = document.body;
@property({type: Boolean})
disableEdit = false;
@property({type: Boolean})
disableDiffPrefs = false;
@property({
type: Boolean,
computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
})
_diffPrefsDisabled?: boolean;
@property({type: Array})
_commentThreads?: CommentThread[];
// TODO(taoalpha): Consider replacing diffDrafts
// with _draftCommentThreads everywhere, currently only
// replaced in reply-dialog
@property({type: Array})
_draftCommentThreads?: CommentThread[];
@property({
type: Array,
computed:
'_computeRobotCommentThreads(_commentThreads,' +
' _currentRobotCommentsPatchSet, _showAllRobotComments)',
})
_robotCommentThreads?: CommentThread[];
@property({type: Object, observer: '_startUpdateCheckTimer'})
_serverConfig?: ServerInfo;
@property({type: Object})
_diffPrefs?: DiffPreferencesInfo;
@property({type: Number, observer: '_numFilesShownChanged'})
_numFilesShown = DEFAULT_NUM_FILES_SHOWN;
@property({type: Object})
_account?: AccountDetailInfo;
@property({type: Object})
_prefs?: PreferencesInfo;
@property({type: Object})
_changeComments?: ChangeComments;
@property({type: Boolean, computed: '_computeCanStartReview(_change)'})
_canStartReview?: boolean;
@property({type: Object, observer: '_changeChanged'})
_change?: ChangeInfo | ParsedChangeInfo;
@property({type: Object, computed: '_getRevisionInfo(_change)'})
_revisionInfo?: RevisionInfoClass;
@property({type: Object})
_commitInfo?: CommitInfo;
@property({
type: Object,
computed:
'_computeCurrentRevision(_change.current_revision, ' +
'_change.revisions)',
observer: '_handleCurrentRevisionUpdate',
})
_currentRevision?: RevisionInfo;
@property({type: String})
_changeNum?: NumericChangeId;
@property({type: Object})
_diffDrafts?: {[path: string]: UIDraft[]} = {};
@property({type: Boolean})
_editingCommitMessage = false;
@property({
type: Boolean,
computed:
'_computeHideEditCommitMessage(_loggedIn, ' +
'_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
'_commitCollapsible)',
})
_hideEditCommitMessage?: boolean;
@property({type: String})
_diffAgainst?: string;
@property({type: String})
_latestCommitMessage: string | null = '';
@property({type: Object})
_constants = {
SecondaryTab,
PrimaryTab,
};
@property({type: Object})
_messages = NO_ROBOT_COMMENTS_THREADS_MSG;
@property({type: Number})
_lineHeight?: number;
@property({
type: String,
computed:
'_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
})
_changeIdCommitMessageError?: string;
@property({type: Object})
_patchRange?: ChangeViewPatchRange;
@property({type: String})
_filesExpanded?: string;
@property({type: String})
_basePatchNum?: string;
@property({type: Object})
_selectedRevision?: RevisionInfo | EditRevisionInfo;
@property({type: Object})
_currentRevisionActions?: ActionNameToActionInfoMap;
@property({
type: Array,
computed: '_computeAllPatchSets(_change, _change.revisions.*)',
})
_allPatchSets?: PatchSet[];
@property({type: Boolean})
_loggedIn = false;
@property({type: Boolean})
_loading?: boolean;
@property({type: Object})
_projectConfig?: ConfigInfo;
@property({
type: String,
computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
})
_replyButtonLabel = 'Reply';
@property({type: String})
_selectedPatchSet?: string;
@property({type: Number})
_shownFileCount?: number;
@property({type: Boolean})
_initialLoadComplete = false;
@property({type: Boolean})
_replyDisabled = true;
@property({type: String, computed: '_changeStatusString(_change)'})
_changeStatus?: string;
@property({
type: String,
computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
})
_changeStatuses?: string[];
/** If false, then the "Show more" button was used to expand. */
@property({type: Boolean})
_commitCollapsed = true;
/** Is the "Show more/less" button visible? */
@property({
type: Boolean,
computed: '_computeCommitCollapsible(_latestCommitMessage)',
})
_commitCollapsible?: boolean;
@property({type: Boolean})
_relatedChangesCollapsed = true;
@property({type: Number})
_updateCheckTimerHandle?: number | null;
@property({
type: Boolean,
computed: '_computeEditMode(_patchRange.*, params.*)',
})
_editMode?: boolean;
@property({type: Boolean, observer: '_updateToggleContainerClass'})
_showRelatedToggle = false;
@property({
type: Boolean,
computed: '_isParentCurrent(_currentRevisionActions)',
})
_parentIsCurrent?: boolean;
@property({
type: Boolean,
computed: '_isSubmitEnabled(_currentRevisionActions)',
})
_submitEnabled?: boolean;
@property({type: Boolean})
_mergeable: boolean | null = null;
@property({type: Boolean})
_showFileTabContent = true;
@property({type: Array})
_dynamicTabHeaderEndpoints: string[] = [];
@property({type: Array})
_dynamicTabContentEndpoints: string[] = [];
@property({type: String})
// The dynamic content of the plugin added tab
_selectedTabPluginEndpoint?: string;
@property({type: String})
// The dynamic heading of the plugin added tab
_selectedTabPluginHeader?: string;
@property({
type: Array,
computed:
'_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
})
_robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
@property({type: Number})
_currentRobotCommentsPatchSet?: PatchSetNum;
// TODO(milutin) - remove once new gr-dialog will do it out of the box
// This removes rest of page from a11y tree, when reply dialog is open
@property({type: Boolean})
_changeViewAriaHidden = false;
/**
* this is a two-element tuple to always
* hold the current active tab for both primary and secondary tabs
*/
@property({type: Array})
_activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
@property({type: Boolean})
_showAllRobotComments = false;
@property({type: Boolean})
_showRobotCommentsButton = false;
_throttledToggleChangeStar?: EventListener;
@property({type: Boolean})
_showChecksTab = false;
@property({type: Boolean})
_isNewChangeSummaryUiEnabled = false;
restApiService = appContext.restApiService;
checksService = appContext.checksService;
keyboardShortcuts() {
return {
[Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
[Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
[Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
[Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
[Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
[Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
[Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
[Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
[Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
[Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
[Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
[Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
[Shortcut.EDIT_TOPIC]: '_handleEditTopic',
[Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
[Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
[Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
[Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
[Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
};
}
disconnected$ = new Subject();
/** @override */
ready() {
super.ready();
aPluginHasRegistered.pipe(takeUntil(this.disconnected$)).subscribe(b => {
this._showChecksTab = b;
});
this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
KnownExperimentId.NEW_CHANGE_SUMMARY_UI
);
}
/** @override */
connectedCallback() {
super.connectedCallback();
this._throttledToggleChangeStar = this._throttleWrap(e =>
this._handleToggleChangeStar(e as CustomKeyboardEvent)
);
}
/** @override */
disconnectedCallback() {
this.disconnected$.next();
super.disconnectedCallback();
}
/** @override */
created() {
super.created();
this.addEventListener('topic-changed', () => this._handleTopicChanged());
this.addEventListener(
// When an overlay is opened in a mobile viewport, the overlay has a full
// screen view. When it has a full screen view, we do not want the
// background to be scrollable. This will eliminate background scroll by
// hiding most of the contents on the screen upon opening, and showing
// again upon closing.
'fullscreen-overlay-opened',
() => this._handleHideBackgroundContent()
);
this.addEventListener('fullscreen-overlay-closed', () =>
this._handleShowBackgroundContent()
);
this.addEventListener('diff-comments-modified', () =>
this._handleReloadCommentThreads()
);
this.addEventListener(
'thread-list-modified',
(e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
);
this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
}
/** @override */
attached() {
super.attached();
this._getServerConfig().then(config => {
this._serverConfig = config;
this._replyDisabled = false;
});
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
if (loggedIn) {
this.restApiService.getAccount().then(acct => {
this._account = acct;
});
}
this._setDiffViewMode();
});
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
'change-view-tab-header'
);
this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
'change-view-tab-content'
);
if (
this._dynamicTabContentEndpoints.length !==
this._dynamicTabHeaderEndpoints.length
) {
console.warn('Different number of tab headers and tab content.');
}
})
.then(() => this._initActiveTabs(this.params));
this.addEventListener('comment-save', e => this._handleCommentSave(e));
this.addEventListener('comment-refresh', () => this._reloadDrafts());
this.addEventListener('comment-discard', e =>
this._handleCommentDiscard(e)
);
this.addEventListener('change-message-deleted', () => this._reload());
this.addEventListener('editable-content-save', e =>
this._handleCommitMessageSave(e)
);
this.addEventListener('editable-content-cancel', () =>
this._handleCommitMessageCancel()
);
this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
this.listen(window, 'scroll', '_handleScroll');
this.listen(document, 'visibilitychange', '_handleVisibilityChange');
this.addEventListener('show-primary-tab', e =>
this._setActivePrimaryTab(e)
);
this.addEventListener('show-secondary-tab', e =>
this._setActiveSecondaryTab(e)
);
this.addEventListener('reload', e => {
e.stopPropagation();
this._reload(
/* isLocationChange= */ false,
/* clearPatchset= */ e.detail && e.detail.clearPatchset
);
});
}
/** @override */
detached() {
super.detached();
this.unlisten(window, 'scroll', '_handleScroll');
this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
if (this._updateCheckTimerHandle) {
this._cancelUpdateCheckTimer();
}
}
get messagesList(): GrMessagesList | null {
return this.shadowRoot!.querySelector<GrMessagesList>('gr-messages-list');
}
get threadList(): GrThreadList | null {
return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
}
_changeStatusString(change: ChangeInfo) {
return changeStatusString(change);
}
_setDiffViewMode(opt_reset?: boolean) {
if (!opt_reset && this.viewState.diffViewMode) {
return;
}
return this._getPreferences()
.then(prefs => {
if (!this.viewState.diffMode && prefs) {
this.set('viewState.diffMode', prefs.default_diff_view);
}
})
.then(() => {
if (!this.viewState.diffMode) {
this.set('viewState.diffMode', 'SIDE_BY_SIDE');
}
});
}
_onOpenFixPreview(e: OpenFixPreviewEvent) {
this.$.applyFixDialog.open(e);
}
_onCloseFixPreview() {
this._reload();
}
_handleToggleDiffMode(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
} else {
this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
}
}
_isTabActive(tab: string, activeTabs: string[]) {
return activeTabs.includes(tab);
}
/**
* Actual implementation of switching a tab
*
* @param paperTabs - the parent tabs container
*/
_setActiveTab(
paperTabs: PaperTabsElement | null,
activeDetails: {
activeTabName?: string;
activeTabIndex?: number;
scrollIntoView?: boolean;
}
) {
if (!paperTabs) return;
const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
HTMLElement
>;
let activeIndex = -1;
if (activeTabIndex !== undefined) {
activeIndex = activeTabIndex;
} else {
for (let i = 0; i <= tabs.length; i++) {
const tab = tabs[i];
if (tab.dataset['name'] === activeTabName) {
activeIndex = i;
break;
}
}
}
if (activeIndex === -1) {
console.warn('tab not found with given info', activeDetails);
return;
}
const tabName = tabs[activeIndex].dataset['name'];
if (scrollIntoView) {
paperTabs.scrollIntoView();
}
if (paperTabs.selected !== activeIndex) {
paperTabs.selected = activeIndex;
this.reporting.reportInteraction('show-tab', {tabName});
}
return tabName;
}
/**
* Changes active primary tab.
*/
_setActivePrimaryTab(e: SwitchTabEvent) {
const primaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
'#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.
*/
_setActiveSecondaryTab(e: SwitchTabEvent) {
const secondaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
'#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: EditableContentSaveEvent) {
if (!this._change) throw new Error('missing required change property');
if (!this._changeNum)
throw new Error('missing required changeNum property');
// Trim trailing whitespace from each line.
const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
this.jsAPI.handleCommitMessage(this._change, message);
this.$.commitMessageEditor.disabled = true;
this.restApiService
.putChangeCommitMessage(this._changeNum, message)
.then(resp => {
this.$.commitMessageEditor.disabled = false;
if (!resp.ok) {
return;
}
this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
this._editingCommitMessage = false;
this._reloadWindow();
})
.catch(() => {
this.$.commitMessageEditor.disabled = false;
});
}
_reloadWindow() {
windowLocationReload();
}
_handleCommitMessageCancel() {
this._editingCommitMessage = false;
}
_computeChangeStatusChips(
change: ChangeInfo | undefined,
mergeable: boolean | null,
submitEnabled?: boolean
) {
if (!change) {
return undefined;
}
// Show no chips until mergeability is loaded.
if (mergeable === null) {
return [];
}
const options = {
includeDerived: true,
mergeable: !!mergeable,
submitEnabled: !!submitEnabled,
};
return changeStatuses(change, options);
}
_computeHideEditCommitMessage(
loggedIn: boolean,
editing: boolean,
change: ChangeInfo,
editMode?: boolean,
collapsed?: boolean,
collapsible?: boolean
) {
const hideWhenCollapsed = this._isNewChangeSummaryUiEnabled
? false
: collapsed && collapsible;
if (
!loggedIn ||
editing ||
(change && change.status === ChangeStatus.MERGED) ||
editMode ||
hideWhenCollapsed
) {
return true;
}
return false;
}
_robotCommentCountPerPatchSet(threads: CommentThread[]) {
return threads.reduce((robotCommentCountMap, thread) => {
const comments = thread.comments;
const robotCommentsCount = comments.reduce(
(acc, comment) => (isRobot(comment) ? acc + 1 : acc),
0
);
if (comments[0].patch_set)
robotCommentCountMap[`${comments[0].patch_set}`] =
(robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
robotCommentsCount;
return robotCommentCountMap;
}, {} as {[patchset: string]: number});
}
_computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
const commentCnt = commentCount[patch._number] || 0;
if (commentCnt === 0) return `Patchset ${patch._number}`;
return `Patchset ${patch._number} (${pluralize(commentCnt, 'finding')})`;
}
_computeRobotCommentsPatchSetDropdownItems(
change: ChangeInfo,
commentThreads: CommentThread[]
) {
if (!change || !commentThreads || !change.revisions) return [];
return Object.values(change.revisions)
.filter(patch => patch._number !== 'edit')
.map(patch => {
return {
text: this._computeText(patch, commentThreads),
value: patch._number,
};
})
.sort((a, b) => (b.value as number) - (a.value as number));
}
_handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
this._currentRobotCommentsPatchSet = currentRevision._number;
}
_handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
const patchSet = Number(e.detail.value) as PatchSetNum;
if (patchSet === this._currentRobotCommentsPatchSet) return;
this._currentRobotCommentsPatchSet = patchSet;
}
_computeShowText(showAllRobotComments: boolean) {
return showAllRobotComments ? 'Show Less' : 'Show more';
}
_toggleShowRobotComments() {
this._showAllRobotComments = !this._showAllRobotComments;
}
_computeRobotCommentThreads(
commentThreads: CommentThread[],
currentRobotCommentsPatchSet: PatchSetNum,
showAllRobotComments: boolean
) {
if (!commentThreads || !currentRobotCommentsPatchSet) return [];
const threads = commentThreads.filter(thread => {
const comments = thread.comments || [];
return (
comments.length &&
isRobot(comments[0]) &&
comments[0].patch_set === currentRobotCommentsPatchSet
);
});
this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
return threads.slice(
0,
showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
);
}
_handleReloadCommentThreads() {
// Get any new drafts that have been saved in the diff view and show
// in the comment thread view.
this._reloadDrafts().then(() => {
this._commentThreads = this._changeComments?.getAllThreadsForChange();
flush();
});
}
_handleReloadDiffComments(
e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
) {
// Keeps the file list counts updated.
this._reloadDrafts().then(() => {
// Get any new drafts that have been saved in the thread view and show
// in the diff view.
this.$.fileList.reloadCommentsForThreadWithRootId(
e.detail.rootId,
e.detail.path
);
flush();
});
}
_computeTotalCommentCounts(
unresolvedCount: number,
changeComments: ChangeComments
) {
if (!changeComments) return undefined;
const draftCount = changeComments.computeDraftCount();
const unresolvedString =
unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
const draftString = pluralize(draftCount, 'draft');
return (
unresolvedString +
// Add a comma and space if both unresolved and draft comments exist.
(unresolvedString && draftString ? ', ' : '') +
draftString
);
}
_handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
const draft = e.detail.comment;
if (!draft.__draft || !draft.path) return;
if (!this._patchRange)
throw new Error('missing required _patchRange property');
draft.patch_set = draft.patch_set || this._patchRange.patchNum;
// The use of path-based notification helpers (set, push) cant be used
// because the paths could contain dots in them. A new object must be
// created to satisfy Polymers dirty checking.
// https://github.com/Polymer/polymer/issues/3127
const diffDrafts = {...this._diffDrafts};
if (!diffDrafts[draft.path]) {
diffDrafts[draft.path] = [draft];
this._diffDrafts = diffDrafts;
return;
}
for (let i = 0; i < diffDrafts[draft.path].length; i++) {
if (diffDrafts[draft.path][i].id === draft.id) {
diffDrafts[draft.path][i] = draft;
this._diffDrafts = diffDrafts;
return;
}
}
diffDrafts[draft.path].push(draft);
diffDrafts[draft.path].sort(
(c1, c2) =>
// No line number means that its a file comment. Sort it above the
// others.
(c1.line || -1) - (c2.line || -1)
);
this._diffDrafts = diffDrafts;
}
_handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
const draft = e.detail.comment;
if (!draft.__draft || !draft.path) {
return;
}
if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
return;
}
let index = -1;
for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
if (this._diffDrafts[draft.path][i].id === draft.id) {
index = i;
break;
}
}
if (index === -1) {
// It may be a draft that hasnt been added to _diffDrafts since it was
// never saved.
return;
}
if (!this._patchRange)
throw new Error('missing required _patchRange property');
draft.patch_set = draft.patch_set || this._patchRange.patchNum;
// The use of path-based notification helpers (set, push) cant be used
// because the paths could contain dots in them. A new object must be
// created to satisfy Polymers dirty checking.
// https://github.com/Polymer/polymer/issues/3127
const diffDrafts = {...this._diffDrafts};
diffDrafts[draft.path].splice(index, 1);
if (diffDrafts[draft.path].length === 0) {
delete diffDrafts[draft.path];
}
this._diffDrafts = diffDrafts;
}
_handleReplyTap(e: MouseEvent) {
e.preventDefault();
this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
}
onReplyOverlayCanceled() {
fireDialogChange(this, {canceled: true});
this._changeViewAriaHidden = false;
}
_handleOpenDiffPrefs() {
this.$.fileList.openDiffPrefs();
}
_handleOpenIncludedInDialog() {
this.$.includedInDialog.loadData().then(() => {
flush();
this.$.includedInOverlay.refit();
});
this.$.includedInOverlay.open();
}
_handleIncludedInDialogClose() {
this.$.includedInOverlay.close();
}
_handleOpenDownloadDialog() {
this.$.downloadOverlay.open().then(() => {
this.$.downloadOverlay.setFocusStops(
this.$.downloadDialog.getFocusStops()
);
this.$.downloadDialog.focus();
});
}
_handleDownloadDialogClose() {
this.$.downloadOverlay.close();
}
_handleOpenUploadHelpDialog() {
this.$.uploadHelpOverlay.open();
}
_handleCloseUploadHelpDialog() {
this.$.uploadHelpOverlay.close();
}
_handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
const msg: string = e.detail.message.message;
const quoteStr =
msg
.split('\n')
.map(line => '> ' + line)
.join('\n') + '\n\n';
this.$.replyDialog.quote = quoteStr;
this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
}
_handleHideBackgroundContent() {
this.$.mainContent.classList.add('overlayOpen');
}
_handleShowBackgroundContent() {
this.$.mainContent.classList.remove('overlayOpen');
}
_handleReplySent() {
this.addEventListener(
'change-details-loaded',
() => {
this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
},
{once: true}
);
this.$.replyOverlay.cancel();
this._reload();
}
_handleReplyCancel() {
this.$.replyOverlay.cancel();
}
_handleReplyAutogrow() {
// If the textarea resizes, we need to re-fit the overlay.
this.debounce(
'reply-overlay-refit',
() => {
this.$.replyOverlay.refit();
},
REPLY_REFIT_DEBOUNCE_INTERVAL_MS
);
}
_handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
let target = this.$.replyDialog.FocusTarget.REVIEWERS;
if (e.detail.value && e.detail.value.ccsOnly) {
target = this.$.replyDialog.FocusTarget.CCS;
}
this._openReplyDialog(target);
}
_handleScroll() {
this.debounce(
'scroll',
() => {
this.viewState.scrollTop = document.body.scrollTop;
},
150
);
}
_setShownFiles(e: CustomEvent<{length: number}>) {
this._shownFileCount = e.detail.length;
}
_expandAllDiffs(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
this.$.fileList.expandAllDiffs();
}
_collapseAllDiffs() {
this.$.fileList.collapseAllDiffs();
}
_paramsChanged(value: AppElementChangeViewParams) {
if (value.view !== GerritView.CHANGE) {
this._initialLoadComplete = false;
return;
}
if (value.changeNum && value.project) {
this.restApiService.setInProjectLookup(value.changeNum, value.project);
}
const patchChanged =
this._patchRange &&
value.patchNum !== undefined &&
value.basePatchNum !== undefined &&
(this._patchRange.patchNum !== value.patchNum ||
this._patchRange.basePatchNum !== value.basePatchNum);
const changeChanged = this._changeNum !== value.changeNum;
let rightPatchNumChanged =
this._patchRange &&
value.patchNum !== undefined &&
this._patchRange.patchNum !== value.patchNum;
const patchRange: ChangeViewPatchRange = {
patchNum: value.patchNum,
basePatchNum: value.basePatchNum || ParentPatchSetNum,
};
this.$.fileList.collapseAllDiffs();
this._patchRange = patchRange;
// If the change has already been loaded and the parameter change is only
// in the patch range, then don't do a full reload.
if (!changeChanged && patchChanged) {
if (!patchRange.patchNum) {
patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
rightPatchNumChanged = true;
}
this._reloadPatchNumDependentResources(rightPatchNumChanged).then(() => {
this._sendShowChangeEvent();
});
return;
}
this._initialLoadComplete = false;
this._changeNum = value.changeNum;
this.getRelatedChangesList()?.clear();
this._reload(true).then(() => {
this._performPostLoadTasks();
});
getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this._initActiveTabs(value);
});
}
_initActiveTabs(params?: AppElementChangeViewParams) {
let primaryTab = PrimaryTab.FILES;
if (params && params.queryMap && params.queryMap.has('tab')) {
primaryTab = params.queryMap.get('tab') as PrimaryTab;
}
this._setActivePrimaryTab(
new CustomEvent('initActiveTab', {
detail: {
tab: primaryTab,
},
})
);
this._setActiveSecondaryTab(
new CustomEvent('initActiveTab', {
detail: {
tab: SecondaryTab.CHANGE_LOG,
},
})
);
}
_sendShowChangeEvent() {
if (!this._patchRange)
throw new Error('missing required _patchRange property');
this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
change: this._change,
patchNum: this._patchRange.patchNum,
info: {mergeable: this._mergeable},
});
}
_performPostLoadTasks() {
this._maybeShowReplyDialog();
this._maybeShowRevertDialog();
this._maybeShowDownloadDialog();
this._sendShowChangeEvent();
this.async(() => {
if (this.viewState.scrollTop) {
document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
} else {
this._maybeScrollToMessage(window.location.hash);
}
this._initialLoadComplete = true;
});
}
@observe('params', '_change')
_paramsAndChangeChanged(
value?: AppElementChangeViewParams,
change?: ChangeInfo
) {
// Polymer 2: check for undefined
if (!value || !change) {
return;
}
if (!this._patchRange)
throw new Error('missing required _patchRange property');
// If the change number or patch range is different, then reset the
// selected file index.
const patchRangeState = this.viewState.patchRange;
if (
this.viewState.changeNum !== this._changeNum ||
!patchRangeState ||
patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
patchRangeState.patchNum !== this._patchRange.patchNum
) {
this._resetFileListViewState();
}
}
_viewStateChanged(viewState: ChangeViewState) {
this._numFilesShown = viewState.numFilesShown
? viewState.numFilesShown
: DEFAULT_NUM_FILES_SHOWN;
}
_numFilesShownChanged(numFilesShown: number) {
this.viewState.numFilesShown = numFilesShown;
}
_handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
const hash = MSG_PREFIX + e.detail.id;
const url = GerritNav.getUrlForChange(
this._change,
this._patchRange.patchNum,
this._patchRange.basePatchNum,
this._editMode,
hash
);
history.replaceState(null, '', url);
}
_maybeScrollToMessage(hash: string) {
if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
}
}
_getLocationSearch() {
// Not inlining to make it easier to test.
return window.location.search;
}
_getUrlParameter(param: string) {
const pageURL = this._getLocationSearch().substring(1);
const vars = pageURL.split('&');
for (let i = 0; i < vars.length; i++) {
const name = vars[i].split('=');
if (name[0] === param) {
return name[0];
}
}
return null;
}
_maybeShowRevertDialog() {
getPluginLoader()
.awaitPluginsLoaded()
.then(() => this._getLoggedIn())
.then(loggedIn => {
if (
!loggedIn ||
!this._change ||
this._change.status !== ChangeStatus.MERGED
) {
// Do not display dialog if not logged-in or the change is not
// merged.
return;
}
if (this._getUrlParameter('revert')) {
this.$.actions.showRevertDialog();
}
});
}
_maybeShowReplyDialog() {
this._getLoggedIn().then(loggedIn => {
if (!loggedIn) {
return;
}
if (this.viewState.showReplyDialog) {
this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
// TODO(kaspern@): Find a better signal for when to call center.
this.async(() => {
this.$.replyOverlay.center();
}, 100);
this.async(() => {
this.$.replyOverlay.center();
}, 1000);
this.set('viewState.showReplyDialog', false);
}
});
}
_maybeShowDownloadDialog() {
if (this.viewState.showDownloadDialog) {
this._handleOpenDownloadDialog();
this.set('viewState.showDownloadDialog', false);
}
}
_resetFileListViewState() {
this.set('viewState.selectedFileIndex', 0);
this.set('viewState.scrollTop', 0);
if (
!!this.viewState.changeNum &&
this.viewState.changeNum !== this._changeNum
) {
// Reset the diff mode to null when navigating from one change to
// another, so that the user's preference is restored.
this._setDiffViewMode(true);
this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
}
this.set('viewState.changeNum', this._changeNum);
this.set('viewState.patchRange', this._patchRange);
}
_changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
if (!change || !this._patchRange || !this._allPatchSets) {
return;
}
// We get the parent first so we keep the original value for basePatchNum
// and not the updated value.
const parent = this._getBasePatchNum(change, this._patchRange);
this.set(
'_patchRange.patchNum',
this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
);
this.set('_patchRange.basePatchNum', parent);
const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
fireTitleChange(this, title);
}
/**
* Gets base patch number, if it is a parent try and decide from
* preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
*/
_getBasePatchNum(
change: ChangeInfo | ParsedChangeInfo,
patchRange: ChangeViewPatchRange
) {
if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
return patchRange.basePatchNum;
}
const revisionInfo = this._getRevisionInfo(change);
if (!revisionInfo) return 'PARENT';
const parentCounts = revisionInfo.getParentCountMap();
// check that there is at least 2 parents otherwise fall back to 1,
// which means there is only one parent.
const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
const preferFirst =
this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT';
if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
return -1;
}
return 'PARENT';
}
_computeChangeUrl(change: ChangeInfo) {
return GerritNav.getUrlForChange(change);
}
_computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
return changeStatus === 'Merged' && current_revision;
}
_computeMergedCommitInfo(
current_revision: CommitId,
revisions: {[revisionId: string]: RevisionInfo}
) {
const rev = revisions[current_revision];
if (!rev || !rev.commit) {
return {};
}
// CommitInfo.commit is optional. Set commit in all cases to avoid error
// in <gr-commit-info>. @see Issue 5337
if (!rev.commit.commit) {
rev.commit.commit = current_revision;
}
return rev.commit;
}
_computeChangeIdClass(displayChangeId: string) {
return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
}
_computeTitleAttributeWarning(displayChangeId: string) {
if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
return 'Change-Id mismatch';
} else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
return 'No Change-Id in commit message';
}
return undefined;
}
_computeChangeIdCommitMessageError(
commitMessage?: string,
change?: ChangeInfo
) {
if (change === undefined) {
return undefined;
}
if (!commitMessage) {
return CHANGE_ID_ERROR.MISSING;
}
// Find the last match in the commit message:
let changeId;
let changeIdArr;
while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
changeId = changeIdArr[2];
}
if (changeId) {
// A change-id is detected in the commit message.
if (changeId === change.change_id) {
// The change-id found matches the real change-id.
return null;
}
// The change-id found does not match the change-id.
return CHANGE_ID_ERROR.MISMATCH;
}
// There is no change-id in the commit message.
return CHANGE_ID_ERROR.MISSING;
}
_computeReplyButtonLabel(
changeRecord?: ElementPropertyDeepChange<
GrChangeView,
'_diffDrafts'
> | null,
canStartReview?: boolean
) {
if (changeRecord === undefined || canStartReview === undefined) {
return 'Reply';
}
const drafts = (changeRecord && changeRecord.base) || {};
const draftCount = Object.keys(drafts).reduce(
(count, file) => count + drafts[file].length,
0
);
let label = canStartReview ? 'Start Review' : 'Reply';
if (draftCount > 0) {
label += ` (${draftCount})`;
}
return label;
}
_handleOpenReplyDialog(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
this._getLoggedIn().then(isLoggedIn => {
if (!isLoggedIn) {
fireEvent(this, 'show-auth-required');
return;
}
e.preventDefault();
this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
});
}
_handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
this._handleOpenDownloadDialog();
}
_handleEditTopic(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
this.$.metadata.editTopic();
}
_handleDiffAgainstBase(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
if (this._patchRange.basePatchNum === ParentPatchSetNum) {
fireAlert(this, 'Base is already selected.');
return;
}
GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
}
_handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
if (this._patchRange.basePatchNum === ParentPatchSetNum) {
fireAlert(this, 'Left is already base.');
return;
}
GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
}
_handleDiffAgainstLatest(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
if (this._patchRange.patchNum === latestPatchNum) {
fireAlert(this, 'Latest is already selected.');
return;
}
GerritNav.navigateToChange(
this._change,
latestPatchNum,
this._patchRange.basePatchNum
);
}
_handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
if (!this._change) throw new Error('missing required change property');
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
if (!this._patchRange)
throw new Error('missing required _patchRange property');
if (this._patchRange.patchNum === latestPatchNum) {
fireAlert(this, 'Right is already latest.');
return;
}
GerritNav.navigateToChange(
this._change,
latestPatchNum,
this._patchRange.patchNum
);
}
_handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
if (
this._patchRange.patchNum === latestPatchNum &&
this._patchRange.basePatchNum === ParentPatchSetNum
) {
fireAlert(this, 'Already diffing base against latest.');
return;
}
GerritNav.navigateToChange(this._change, latestPatchNum);
}
_handleRefreshChange(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e)) {
return;
}
e.preventDefault();
this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
}
_handleToggleChangeStar(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
this.$.changeStar.toggleStar();
}
_handleUpToDashboard(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
this._determinePageBack();
}
_handleExpandAllMessages(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
if (this.messagesList) {
this.messagesList.handleExpandCollapse(true);
}
}
_handleCollapseAllMessages(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
e.preventDefault();
if (this.messagesList) {
this.messagesList.handleExpandCollapse(false);
}
}
_handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
return;
}
if (this._diffPrefsDisabled) {
return;
}
e.preventDefault();
this.$.fileList.openDiffPrefs();
}
_determinePageBack() {
// Default backPage to root if user came to change view page
// via an email link, etc.
GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
}
_handleLabelRemoved(
splices: Array<PolymerSplice<ApprovalInfo[]>>,
path: string
) {
for (const splice of splices) {
for (const removed of splice.removed) {
const changePath = path.split('.');
const labelPath = changePath.splice(0, changePath.length - 2);
const labelDict = this.get(labelPath) as QuickLabelInfo;
if (
labelDict.approved &&
labelDict.approved._account_id === removed._account_id
) {
this._reload();
return;
}
}
}
}
@observe('_change.labels.*')
_labelsChanged(
changeRecord: PolymerDeepPropertyChange<
LabelNameToInfoMap,
PolymerSpliceChange<ApprovalInfo[]>
>
) {
if (!changeRecord) {
return;
}
if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
this._handleLabelRemoved(
changeRecord.value.indexSplices,
changeRecord.path
);
}
this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
change: this._change,
});
}
_openReplyDialog(section?: FocusTarget) {
this.$.replyOverlay.open().finally(() => {
// the following code should be executed no matter open succeed or not
this._resetReplyOverlayFocusStops();
this.$.replyDialog.open(section);
flush();
this.$.replyOverlay.center();
});
fireDialogChange(this, {opened: true});
this._changeViewAriaHidden = true;
}
_handleGetChangeDetailError(response?: Response | null) {
firePageError(response);
}
_getLoggedIn() {
return this.restApiService.getLoggedIn();
}
_getServerConfig() {
return this.restApiService.getConfig();
}
_getProjectConfig() {
if (!this._change) throw new Error('missing required change property');
return this.restApiService
.getProjectConfig(this._change.project)
.then(config => {
this._projectConfig = config;
});
}
_getPreferences() {
return this.restApiService.getPreferences();
}
_prepareCommitMsgForLinkify(msg: string) {
// TODO(wyatta) switch linkify sequence, see issue 5526.
// This is a zero-with space. It is added to prevent the linkify library
// from including R= or CC= as part of the email address.
return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
}
/**
* Utility function to make the necessary modifications to a change in the
* case an edit exists.
*/
_processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
if (!edit) return;
if (!this._patchRange)
throw new Error('missing required _patchRange property');
if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
const changeWithEdit = change;
if (changeWithEdit.revisions)
changeWithEdit.revisions[edit.commit.commit] = {
_number: EditPatchSetNum,
basePatchNum: edit.base_patch_set_number,
commit: edit.commit,
fetch: edit.fetch,
};
// If the edit is based on the most recent patchset, load it by
// default, unless another patch set to load was specified in the URL.
if (
!this._patchRange.patchNum &&
changeWithEdit.current_revision === edit.base_revision
) {
changeWithEdit.current_revision = edit.commit.commit;
this.set('_patchRange.patchNum', EditPatchSetNum);
// Because edits are fibbed as revisions and added to the revisions
// array, and revision actions are always derived from the 'latest'
// patch set, we must copy over actions from the patch set base.
// Context: Issue 7243
if (changeWithEdit.revisions) {
changeWithEdit.revisions[edit.commit.commit].actions =
changeWithEdit.revisions[edit.base_revision].actions;
}
}
}
_getChangeDetail() {
if (!this._changeNum)
throw new Error('missing required changeNum property');
const detailCompletes = this.restApiService.getChangeDetail(
this._changeNum,
r => this._handleGetChangeDetailError(r)
);
const editCompletes = this._getEdit();
const prefCompletes = this._getPreferences();
return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
([change, edit, prefs]) => {
this._prefs = prefs;
if (!change) {
return false;
}
this._processEdit(change, edit);
// Issue 4190: Coalesce missing topics to null.
// TODO(TS): code needs second thought,
// it might be that nulls were assigned to trigger some bindings
if (!change.topic) {
change.topic = (null as unknown) as undefined;
}
if (!change.reviewer_updates) {
change.reviewer_updates = (null as unknown) as undefined;
}
const latestRevisionSha = this._getLatestRevisionSHA(change);
if (!latestRevisionSha)
throw new Error('Could not find latest Revision Sha');
const currentRevision = change.revisions[latestRevisionSha];
if (currentRevision.commit && currentRevision.commit.message) {
this._latestCommitMessage = this._prepareCommitMsgForLinkify(
currentRevision.commit.message
);
} else {
this._latestCommitMessage = null;
}
const lineHeight = getComputedStyle(this).lineHeight;
// Slice returns a number as a string, convert to an int.
this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
this._change = change;
if (
!this._patchRange ||
!this._patchRange.patchNum ||
this._patchRange.patchNum === currentRevision._number
) {
// CommitInfo.commit is optional, and may need patching.
if (currentRevision.commit && !currentRevision.commit.commit) {
currentRevision.commit.commit = latestRevisionSha as CommitId;
}
this._commitInfo = currentRevision.commit;
this._selectedRevision = currentRevision;
// TODO: Fetch and process files.
} else {
if (!this._change?.revisions || !this._patchRange) return false;
this._selectedRevision = Object.values(this._change.revisions).find(
revision => {
// edit patchset is a special one
const thePatchNum = this._patchRange!.patchNum;
if (thePatchNum === 'edit') {
return revision._number === thePatchNum;
}
return revision._number === Number(`${thePatchNum}`);
}
);
}
return false;
}
);
}
_isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
return !!(
revisionActions &&
revisionActions.submit &&
revisionActions.submit.enabled
);
}
_isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
if (revisionActions && revisionActions.rebase) {
return !revisionActions.rebase.enabled;
} else {
return true;
}
}
_getEdit() {
if (!this._changeNum)
return Promise.reject(new Error('missing required changeNum property'));
return this.restApiService.getChangeEdit(this._changeNum, true);
}
_getLatestCommitMessage() {
if (!this._changeNum)
throw new Error('missing required changeNum property');
const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
if (lastpatchNum === undefined)
throw new Error('missing lastPatchNum property');
return this.restApiService
.getChangeCommitInfo(this._changeNum, lastpatchNum)
.then(commitInfo => {
if (!commitInfo) return;
this._latestCommitMessage = this._prepareCommitMsgForLinkify(
commitInfo.message
);
});
}
_getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
if (change.current_revision) return change.current_revision;
// current_revision may not be present in the case where the latest rev is
// a draft and the user doesnt have permission to view that rev.
let latestRev = null;
let latestPatchNum = -1 as PatchSetNum;
for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
if (revInfo._number > latestPatchNum) {
latestRev = rev;
latestPatchNum = revInfo._number;
}
}
return latestRev;
}
_getCommitInfo() {
if (!this._changeNum)
throw new Error('missing required changeNum property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
if (this._patchRange.patchNum === undefined)
throw new Error('missing required patchNum property');
return this.restApiService
.getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
.then(commitInfo => {
this._commitInfo = commitInfo;
});
}
_reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
return this._reloadDrafts().then(() => e.detail.resolve());
}
/**
* Fetches a new changeComment object, and data for all types of comments
* (comments, robot comments, draft comments) is requested.
*/
_reloadComments() {
// We are resetting all comment related properties, because we want to avoid
// a new change being loaded and then paired with outdated comments.
this._changeComments = undefined;
this._commentThreads = undefined;
this._diffDrafts = undefined;
this._draftCommentThreads = undefined;
this._robotCommentThreads = undefined;
if (!this._changeNum)
throw new Error('missing required changeNum property');
return this.$.commentAPI
.loadAll(this._changeNum, this._patchRange?.patchNum)
.then(comments => {
this._recomputeComments(comments);
});
}
/**
* Fetches a new changeComment object, but only updated data for drafts is
* requested.
*
* TODO(taoalpha): clean up this and _reloadComments, as single comment
* can be a thread so it does not make sense to only update drafts
* without updating threads
*/
_reloadDrafts() {
if (!this._changeNum)
throw new Error('missing required changeNum property');
return this.$.commentAPI
.reloadDrafts(this._changeNum)
.then(comments => this._recomputeComments(comments));
}
_recomputeComments(comments: ChangeComments) {
this._changeComments = comments;
this._diffDrafts = {...this._changeComments.drafts};
this._commentThreads = this._changeComments.getAllThreadsForChange();
this._draftCommentThreads = this._commentThreads
.filter(isDraftThread)
.map(thread => {
const copiedThread = {...thread};
// Make a hardcopy of all comments and collapse all but last one
const commentsInThread = (copiedThread.comments = thread.comments.map(
comment => {
return {...comment, collapsed: true as boolean};
}
));
commentsInThread[commentsInThread.length - 1].collapsed = false;
return copiedThread;
});
}
/**
* Reload the change.
*
* @param isLocationChange Reloads the related changes
* when true and ends reporting events that started on location change.
* @param clearPatchset Reloads the related changes
* ignoring any patchset choice made.
* @return A promise that resolves when the core data has loaded.
* Some non-core data loading may still be in-flight when the core data
* promise resolves.
*/
_reload(isLocationChange?: boolean, clearPatchset?: boolean) {
if (clearPatchset && this._change) {
GerritNav.navigateToChange(this._change);
return Promise.resolve([]);
}
this._loading = true;
this._relatedChangesCollapsed = true;
this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
this.reporting.time(CHANGE_DATA_TIMING_LABEL);
// Array to house all promises related to data requests.
const allDataPromises: Promise<unknown>[] = [];
// Resolves when the change detail and the edit patch set (if available)
// are loaded.
const detailCompletes = this._getChangeDetail();
allDataPromises.push(detailCompletes);
this.checksService.reloadAll();
// Resolves when the loading flag is set to false, meaning that some
// change content may start appearing.
const loadingFlagSet = detailCompletes
.then(() => {
this._loading = false;
fireEvent(this, 'change-details-loaded');
})
.then(() => {
this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
if (isLocationChange) {
this.reporting.changeDisplayed();
}
});
// Resolves when the project config has loaded.
const projectConfigLoaded = detailCompletes.then(() =>
this._getProjectConfig()
);
allDataPromises.push(projectConfigLoaded);
// Resolves when change comments have loaded (comments, drafts and robot
// comments).
const commentsLoaded = this._reloadComments();
allDataPromises.push(commentsLoaded);
let coreDataPromise;
// If the patch number is specified
if (this._patchRange && this._patchRange.patchNum) {
// Because a specific patchset is specified, reload the resources that
// are keyed by patch number or patch range.
const patchResourcesLoaded = this._reloadPatchNumDependentResources();
allDataPromises.push(patchResourcesLoaded);
// Promise resolves when the change detail and patch dependent resources
// have loaded.
const detailAndPatchResourcesLoaded = Promise.all([
patchResourcesLoaded,
loadingFlagSet,
]);
// Promise resolves when mergeability information has loaded.
const mergeabilityLoaded = detailAndPatchResourcesLoaded.then(() =>
this._getMergeability()
);
allDataPromises.push(mergeabilityLoaded);
// Promise resovles when the change actions have loaded.
const actionsLoaded = detailAndPatchResourcesLoaded.then(() =>
this.$.actions.reload()
);
allDataPromises.push(actionsLoaded);
// The core data is loaded when both mergeability and actions are known.
coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
} else {
// Resolves when the file list has loaded.
const fileListReload = loadingFlagSet.then(() =>
this.$.fileList.reload()
);
allDataPromises.push(fileListReload);
const latestCommitMessageLoaded = loadingFlagSet.then(() => {
// If the latest commit message is known, there is nothing to do.
if (this._latestCommitMessage) {
return Promise.resolve();
}
return this._getLatestCommitMessage();
});
allDataPromises.push(latestCommitMessageLoaded);
// Promise resolves when mergeability information has loaded.
const mergeabilityLoaded = loadingFlagSet.then(() =>
this._getMergeability()
);
allDataPromises.push(mergeabilityLoaded);
// Core data is loaded when mergeability has been loaded.
coreDataPromise = Promise.all([mergeabilityLoaded]);
}
if (isLocationChange) {
this._editingCommitMessage = false;
const relatedChangesLoaded = coreDataPromise.then(() => {
this.getRelatedChangesList()?.reload();
if (this._isNewChangeSummaryUiEnabled) {
this.getRelatedChangesListExperimental()?.reload();
}
});
allDataPromises.push(relatedChangesLoaded);
}
Promise.all(allDataPromises).then(() => {
this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
if (isLocationChange) {
this.reporting.changeFullyLoaded();
}
});
return coreDataPromise;
}
/**
* Kicks off requests for resources that rely on the patch range
* (`this._patchRange`) being defined.
*/
_reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) {
if (!this._changeNum) throw new Error('missing changeNum');
if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
const promises = [this._getCommitInfo(), this.$.fileList.reload()];
if (rightPatchNumChanged)
promises.push(
this.$.commentAPI.reloadPortedComments(
this._changeNum,
this._patchRange?.patchNum
)
);
return Promise.all(promises);
}
_getMergeability() {
if (!this._change) {
this._mergeable = null;
return Promise.resolve();
}
// If the change is closed, it is not mergeable. Note: already merged
// changes are obviously not mergeable, but the mergeability API will not
// answer for abandoned changes.
if (
this._change.status === ChangeStatus.MERGED ||
this._change.status === ChangeStatus.ABANDONED
) {
this._mergeable = false;
return Promise.resolve();
}
if (!this._changeNum) {
return Promise.reject(new Error('missing required changeNum property'));
}
this._mergeable = null;
return this.restApiService
.getMergeable(this._changeNum)
.then(mergableInfo => {
if (mergableInfo) {
this._mergeable = mergableInfo.mergeable;
}
});
}
_computeCanStartReview(change: ChangeInfo) {
return !!(
change.actions &&
change.actions.ready &&
change.actions.ready.enabled
);
}
_computeReplyDisabled() {
return false;
}
_computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
return `Change ${changeNum}`;
}
_computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
if (this._isNewChangeSummaryUiEnabled) {
return false;
}
return collapsible && collapsed;
}
_computeRelatedChangesClass(collapsed: boolean) {
return collapsed ? 'collapsed' : '';
}
_computeCollapseText(collapsed: boolean) {
// Symbols are up and down triangles.
return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
}
/**
* Returns the text to be copied when
* click the copy icon next to change subject
*/
_computeCopyTextForTitle(change: ChangeInfo) {
return (
`${change._number}: ${change.subject} | ` +
`${location.protocol}//${location.host}` +
`${this._computeChangeUrl(change)}`
);
}
_toggleCommitCollapsed() {
this._commitCollapsed = !this._commitCollapsed;
if (this._commitCollapsed) {
window.scrollTo(0, 0);
}
}
_toggleRelatedChangesCollapsed() {
this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
if (this._relatedChangesCollapsed) {
window.scrollTo(0, 0);
}
}
_computeCommitCollapsible(commitMessage?: string) {
if (!commitMessage) {
return false;
}
const MIN_LINES = this._isNewChangeSummaryUiEnabled
? 15
: MIN_LINES_FOR_COMMIT_COLLAPSE;
return commitMessage.split('\n').length >= MIN_LINES;
}
_getOffsetHeight(element: HTMLElement) {
return element.offsetHeight;
}
_getScrollHeight(element: HTMLElement) {
return element.scrollHeight;
}
/**
* Get the line height of an element to the nearest integer.
*/
_getLineHeight(element: Element) {
const lineHeightStr = getComputedStyle(element).lineHeight;
return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
}
/**
* New max height for the related changes section, shorter than the existing
* change info height.
*/
_updateRelatedChangeMaxHeight() {
// Takes into account approximate height for the expand button and
// bottom margin.
const EXTRA_HEIGHT = 30;
let newHeight;
if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`).matches) {
// In a small (mobile) view, give the relation chain some space.
newHeight = SMALL_RELATED_HEIGHT;
} else if (
window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`).matches
) {
// Since related changes are below the commit message, but still next to
// metadata, the height should be the height of the metadata minus the
// height of the commit message to reduce jank. However, if that doesn't
// result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
// Note: extraHeight is to take into account margin/padding.
const medRelatedHeight = Math.max(
this._getOffsetHeight(this.$.mainChangeInfo) -
this._getOffsetHeight(this.$.commitMessage) -
2 * EXTRA_HEIGHT,
MINIMUM_RELATED_MAX_HEIGHT
);
newHeight = medRelatedHeight;
} else {
if (this._commitCollapsible) {
// Make sure the content is lined up if both areas have buttons. If
// the commit message is not collapsed, instead use the change info
// height.
newHeight = this._getOffsetHeight(this.$.commitMessage);
} else {
newHeight =
this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
}
}
const stylesToUpdate: {[key: string]: string} = {};
const relatedChanges = this.getRelatedChangesList();
// Get the line height of related changes, and convert it to the nearest
// integer.
const DEFAULT_LINE_HEIGHT = 20;
const lineHeight = relatedChanges
? this._getLineHeight(relatedChanges)
: DEFAULT_LINE_HEIGHT;
// Figure out a new height that is divisible by the rounded line height.
const remainder = newHeight % lineHeight;
newHeight = newHeight - remainder;
stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
// Update the max-height of the relation chain to this new height.
if (this._commitCollapsible) {
stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
}
this.updateStyles(stylesToUpdate);
}
_computeShowRelatedToggle() {
// Make sure the max height has been applied, since there is now content
// to populate.
if (!getComputedStyleValue('--relation-chain-max-height', this)) {
this._updateRelatedChangeMaxHeight();
}
// Prevents showMore from showing when click on related change, since the
// line height would be positive, but related changes height is 0.
const relatedChanges = this.getRelatedChangesList();
if (relatedChanges) {
if (!this._getScrollHeight(relatedChanges)) {
return (this._showRelatedToggle = false);
}
if (
this._getScrollHeight(relatedChanges) >
this._getOffsetHeight(relatedChanges) +
this._getLineHeight(relatedChanges)
) {
return (this._showRelatedToggle = true);
}
}
return (this._showRelatedToggle = false);
}
_updateToggleContainerClass(showRelatedToggle: boolean) {
const relatedChangesToggle = this.shadowRoot!.querySelector<HTMLDivElement>(
'#relatedChangesToggle'
);
if (!relatedChangesToggle) {
return;
}
if (showRelatedToggle) {
relatedChangesToggle.classList.add('showToggle');
} else {
relatedChangesToggle.classList.remove('showToggle');
}
}
_startUpdateCheckTimer() {
if (
!this._serverConfig ||
!this._serverConfig.change ||
this._serverConfig.change.update_delay === undefined ||
this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
) {
return;
}
this._updateCheckTimerHandle = this.async(() => {
if (!this._change) throw new Error('missing required change property');
const change = this._change;
fetchChangeUpdates(change, this.restApiService).then(result => {
let toastMessage = null;
if (!result.isLatest) {
toastMessage = ReloadToastMessage.NEWER_REVISION;
} else if (result.newStatus === ChangeStatus.MERGED) {
toastMessage = ReloadToastMessage.MERGED;
} else if (result.newStatus === ChangeStatus.ABANDONED) {
toastMessage = ReloadToastMessage.ABANDONED;
} else if (result.newStatus === ChangeStatus.NEW) {
toastMessage = ReloadToastMessage.RESTORED;
} else if (result.newMessages) {
toastMessage = ReloadToastMessage.NEW_MESSAGE;
if (result.newMessages.author?.name) {
toastMessage += ` from ${result.newMessages.author.name}`;
}
}
// We have to make sure that the update is still relevant for the user.
// Since starting to fetch the change update the user may have sent a
// reply, or the change might have been reloaded, or it could be in the
// process of being reloaded.
const changeWasReloaded = change !== this._change;
if (!toastMessage || this._loading || changeWasReloaded) {
this._startUpdateCheckTimer();
return;
}
this._cancelUpdateCheckTimer();
this.dispatchEvent(
new CustomEvent<ShowAlertEventDetail>('show-alert', {
detail: {
message: toastMessage,
// Persist this alert.
dismissOnNavigation: true,
showDismiss: true,
action: 'Reload',
callback: () => {
this._reload(
/* isLocationChange= */ false,
/* clearPatchset= */ true
);
},
},
composed: true,
bubbles: true,
})
);
});
}, this._serverConfig.change.update_delay * 1000);
}
_cancelUpdateCheckTimer() {
if (this._updateCheckTimerHandle) {
this.cancelAsync(this._updateCheckTimerHandle);
}
this._updateCheckTimerHandle = null;
}
_handleVisibilityChange() {
if (document.hidden && this._updateCheckTimerHandle) {
this._cancelUpdateCheckTimer();
} else if (!this._updateCheckTimerHandle) {
this._startUpdateCheckTimer();
}
}
_handleTopicChanged() {
this.getRelatedChangesList()?.reload();
}
_computeHeaderClass(editMode?: boolean) {
const classes = ['header'];
if (editMode) {
classes.push('editMode');
}
return classes.join(' ');
}
_computeEditMode(
patchRangeRecord: PolymerDeepPropertyChange<
ChangeViewPatchRange,
ChangeViewPatchRange
>,
paramsRecord: PolymerDeepPropertyChange<
AppElementChangeViewParams,
AppElementChangeViewParams
>
) {
if (!patchRangeRecord || !paramsRecord) {
return undefined;
}
if (paramsRecord.base && paramsRecord.base.edit) {
return true;
}
const patchRange = patchRangeRecord.base || {};
return patchRange.patchNum === EditPatchSetNum;
}
_handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
e.preventDefault();
const controls = this.$.fileListHeader.shadowRoot!.querySelector<
GrEditControls
>('#editControls');
if (!controls) throw new Error('Missing edit controls');
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
const path = e.detail.path;
switch (e.detail.action) {
case GrEditConstants.Actions.DELETE.id:
controls.openDeleteDialog(path);
break;
case GrEditConstants.Actions.OPEN.id:
GerritNav.navigateToRelativeUrl(
GerritNav.getEditUrlForDiff(
this._change,
path,
this._patchRange.patchNum
)
);
break;
case GrEditConstants.Actions.RENAME.id:
controls.openRenameDialog(path);
break;
case GrEditConstants.Actions.RESTORE.id:
controls.openRestoreDialog(path);
break;
}
}
_computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
return `c${number}_rev${revision}`;
}
@observe('_patchRange.patchNum')
_patchNumChanged(patchNumStr: PatchSetNum) {
if (!this._selectedRevision) {
return;
}
if (!this._change) throw new Error('missing required change property');
let patchNum: PatchSetNum;
if (patchNumStr === 'edit') {
patchNum = EditPatchSetNum;
} else {
patchNum = Number(`${patchNumStr}`) as PatchSetNum;
}
if (patchNum === this._selectedRevision._number) {
return;
}
if (this._change.revisions)
this._selectedRevision = Object.values(this._change.revisions).find(
revision => revision._number === patchNum
);
}
/**
* If an edit exists already, load it. Otherwise, toggle edit mode via the
* navigation API.
*/
_handleEditTap() {
if (!this._change || !this._change.revisions)
throw new Error('missing required change property');
const editInfo = Object.values(this._change.revisions).find(
info => info._number === EditPatchSetNum
);
if (editInfo) {
GerritNav.navigateToChange(this._change, EditPatchSetNum);
return;
}
// Avoid putting patch set in the URL unless a non-latest patch set is
// selected.
if (!this._patchRange)
throw new Error('missing required _patchRange property');
let patchNum;
if (
!(this._patchRange.patchNum === computeLatestPatchNum(this._allPatchSets))
) {
patchNum = this._patchRange.patchNum;
}
GerritNav.navigateToChange(this._change, patchNum, undefined, true);
}
_handleStopEditTap() {
if (!this._change) throw new Error('missing required change property');
if (!this._patchRange)
throw new Error('missing required _patchRange property');
GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
}
_resetReplyOverlayFocusStops() {
this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
}
_handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
this.restApiService.saveChangeStarred(
e.detail.change._number,
e.detail.starred
);
}
_getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
return new RevisionInfoClass(change);
}
_computeCurrentRevision(
currentRevision: CommitId,
revisions: {[revisionId: string]: RevisionInfo}
) {
return currentRevision && revisions && revisions[currentRevision];
}
_computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
return disableDiffPrefs || !loggedIn;
}
/**
* Wrapper for using in the element template and computed properties
*/
_computeLatestPatchNum(allPatchSets: PatchSet[]) {
return computeLatestPatchNum(allPatchSets);
}
/**
* Wrapper for using in the element template and computed properties
*/
_hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
return hasEditBasedOnCurrentPatchSet(allPatchSets);
}
/**
* Wrapper for using in the element template and computed properties
*/
_hasEditPatchsetLoaded(
patchRangeRecord: PolymerDeepPropertyChange<
ChangeViewPatchRange,
ChangeViewPatchRange
>
) {
const patchRange = patchRangeRecord.base;
if (!patchRange) {
return false;
}
return hasEditPatchsetLoaded(patchRange);
}
/**
* Wrapper for using in the element template and computed properties
*/
_computeAllPatchSets(change: ChangeInfo) {
return computeAllPatchSets(change);
}
getRelatedChangesList() {
return this.shadowRoot!.querySelector<GrRelatedChangesList>(
'#relatedChanges'
);
}
getRelatedChangesListExperimental() {
return this.shadowRoot!.querySelector<GrRelatedChangesListExperimental>(
'#relatedChangesExperimental'
);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-view': GrChangeView;
}
}