/** * @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; @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 = {}; @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('gr-messages-list'); } get threadList(): GrThreadList | null { return this.shadowRoot!.querySelector('gr-thread-list'); } _changeStatusString(change: ChangeInfo) { return changeStatusString(change); } _setDiffViewMode(opt_reset?: boolean) { if (!opt_reset && this.viewState.diffViewMode) { return; } return this._getPreferences() .then(prefs => { if (!this.viewState.diffMode && prefs) { this.set('viewState.diffMode', prefs.default_diff_view); } }) .then(() => { if (!this.viewState.diffMode) { this.set('viewState.diffMode', 'SIDE_BY_SIDE'); } }); } _onOpenFixPreview(e: 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( '#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( '#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) can’t be used // because the paths could contain dots in them. A new object must be // created to satisfy Polymer’s dirty checking. // https://github.com/Polymer/polymer/issues/3127 const diffDrafts = {...this._diffDrafts}; if (!diffDrafts[draft.path]) { diffDrafts[draft.path] = [draft]; this._diffDrafts = diffDrafts; return; } for (let i = 0; i < diffDrafts[draft.path].length; i++) { if (diffDrafts[draft.path][i].id === draft.id) { diffDrafts[draft.path][i] = draft; this._diffDrafts = diffDrafts; return; } } diffDrafts[draft.path].push(draft); diffDrafts[draft.path].sort( (c1, c2) => // No line number means that it’s a file comment. Sort it above the // others. (c1.line || -1) - (c2.line || -1) ); this._diffDrafts = diffDrafts; } _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) { const draft = e.detail.comment; if (!draft.__draft || !draft.path) { return; } if (!this._diffDrafts || !this._diffDrafts[draft.path]) { return; } let index = -1; for (let i = 0; i < this._diffDrafts[draft.path].length; i++) { if (this._diffDrafts[draft.path][i].id === draft.id) { index = i; break; } } if (index === -1) { // It may be a draft that hasn’t been added to _diffDrafts since it was // never saved. return; } if (!this._patchRange) throw new Error('missing required _patchRange property'); draft.patch_set = draft.patch_set || this._patchRange.patchNum; // The use of path-based notification helpers (set, push) can’t be used // because the paths could contain dots in them. A new object must be // created to satisfy Polymer’s dirty checking. // https://github.com/Polymer/polymer/issues/3127 const diffDrafts = {...this._diffDrafts}; diffDrafts[draft.path].splice(index, 1); if (diffDrafts[draft.path].length === 0) { delete diffDrafts[draft.path]; } this._diffDrafts = diffDrafts; } _handleReplyTap(e: MouseEvent) { e.preventDefault(); this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY); } 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 . @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>, 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 > ) { 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 doesn’t 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[] = []; // 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( '#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('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( '#relatedChanges' ); } getRelatedChangesListExperimental() { return this.shadowRoot!.querySelector( '#relatedChangesExperimental' ); } } declare global { interface HTMLElementTagNameMap { 'gr-change-view': GrChangeView; } }