| /** |
| * @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/gr-a11y-styles'; |
| 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-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 '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog'; |
| import '../gr-reply-dialog/gr-reply-dialog'; |
| import '../gr-thread-list/gr-thread-list'; |
| import '../../checks/gr-checks-tab'; |
| import {flush} from '@polymer/polymer/lib/legacy/polymer.dom'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-change-view_html'; |
| import { |
| KeyboardShortcutMixin, |
| Shortcut, |
| ShortcutSection, |
| } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin'; |
| import {GrEditConstants} from '../../edit/gr-edit-constants'; |
| import {pluralize} from '../../../utils/string-util'; |
| import {windowLocationReload, querySelectorAll} from '../../../utils/dom-util'; |
| import { |
| GeneratedWebLink, |
| 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 { |
| DefaultBase, |
| ChangeStatus, |
| PrimaryTab, |
| SecondaryTab, |
| } from '../../../constants/constants'; |
| |
| import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages'; |
| import {appContext} from '../../../services/app-context'; |
| import { |
| computeAllPatchSets, |
| computeLatestPatchNum, |
| hasEditBasedOnCurrentPatchSet, |
| hasEditPatchsetLoaded, |
| PatchSet, |
| } from '../../../utils/patch-set-util'; |
| import { |
| changeIsAbandoned, |
| changeIsMerged, |
| changeIsOpen, |
| changeStatuses, |
| isCc, |
| isOwner, |
| isReviewer, |
| isInvolved, |
| } from '../../../utils/change-util'; |
| import {EventType as PluginEventType} from '../../../api/plugin'; |
| import {customElement, observe, property} 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, |
| ActionNameToActionInfoMap, |
| ApprovalInfo, |
| BasePatchSetNum, |
| ChangeId, |
| ChangeInfo, |
| CommitId, |
| CommitInfo, |
| ConfigInfo, |
| EditInfo, |
| EditPatchSetNum, |
| LabelNameToInfoMap, |
| NumericChangeId, |
| ParentPatchSetNum, |
| PatchRange, |
| PatchSetNum, |
| PreferencesInfo, |
| QuickLabelInfo, |
| RelatedChangeAndCommitInfo, |
| RelatedChangesInfo, |
| RevisionInfo, |
| ServerInfo, |
| UrlEncodedCommentId, |
| } from '../../../types/common'; |
| import {DiffPreferencesInfo} from '../../../types/diff'; |
| import {FocusTarget, GrReplyDialog} 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 { |
| ChangeComments, |
| GrCommentApi, |
| } from '../../diff/gr-comment-api/gr-comment-api'; |
| import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util'; |
| import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls'; |
| import { |
| CommentThread, |
| isDraftThread, |
| isRobot, |
| isUnresolved, |
| UIDraft, |
| } from '../../../utils/comment-util'; |
| import { |
| PolymerDeepPropertyChange, |
| PolymerSplice, |
| PolymerSpliceChange, |
| } 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 { |
| DEFAULT_NUM_FILES_SHOWN, |
| GrFileList, |
| } from '../gr-file-list/gr-file-list'; |
| import { |
| ChangeViewState, |
| EditRevisionInfo, |
| isPolymerSpliceChange, |
| ParsedChangeInfo, |
| } from '../../../types/types'; |
| import { |
| IronKeyboardEventListener, |
| CloseFixPreviewEvent, |
| IronKeyboardEvent, |
| EditableContentSaveEvent, |
| EventType, |
| OpenFixPreviewEvent, |
| ShowAlertEventDetail, |
| SwitchTabEvent, |
| TabState, |
| } 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, |
| fireDialogChange, |
| fireEvent, |
| firePageError, |
| fireReload, |
| fireTitleChange, |
| } from '../../../utils/event-util'; |
| import {GerritView, routerView$} from '../../../services/router/router-model'; |
| import {takeUntil} from 'rxjs/operators'; |
| import {aPluginHasRegistered$} from '../../../services/checks/checks-model'; |
| import {Subject} from 'rxjs'; |
| import {debounce, DelayedTask, throttleWrap} from '../../../utils/async-util'; |
| import {Interaction, Timing} from '../../../constants/reporting'; |
| import {ChangeStates} from '../../shared/gr-change-status/gr-change-status'; |
| import {getRevertCreatedChangeIds} from '../../../utils/message-util'; |
| import { |
| changeComments$, |
| drafts$, |
| } from '../../../services/comments/comments-model'; |
| import { |
| hasAttention, |
| getAddedByReason, |
| getRemovedByReason, |
| } from '../../../utils/attention-set-util'; |
| |
| const MIN_LINES_FOR_COMMIT_COLLAPSE = 18; |
| |
| const REVIEWERS_REGEX = /^(R|CC)=/gm; |
| const MIN_CHECK_INTERVAL_SECS = 0; |
| |
| const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500; |
| |
| const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000; |
| |
| const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm; |
| |
| const 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', |
| }; |
| |
| // 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; |
| 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>; |
| |
| // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error. |
| const base = KeyboardShortcutMixin(PolymerElement); |
| |
| @customElement('gr-change-view') |
| export class GrChangeView extends base { |
| 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 |
| */ |
| |
| private readonly reporting = appContext.reportingService; |
| |
| private readonly jsAPI = appContext.jsApiService; |
| |
| private readonly changeService = appContext.changeService; |
| |
| /** |
| * 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)', |
| }) |
| _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: 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: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)', |
| }) |
| _changeStatuses?: ChangeStates[]; |
| |
| /** 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: Number}) |
| _updateCheckTimerHandle?: number | null; |
| |
| @property({ |
| type: Boolean, |
| computed: '_computeEditMode(_patchRange.*, params.*)', |
| }) |
| _editMode?: boolean; |
| |
| @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}) |
| unresolvedOnly = false; |
| |
| @property({type: Boolean}) |
| _showAllRobotComments = false; |
| |
| @property({type: Boolean}) |
| _showRobotCommentsButton = false; |
| |
| _throttledToggleChangeStar?: IronKeyboardEventListener; |
| |
| @property({type: Boolean}) |
| _showChecksTab = false; |
| |
| @property({type: Boolean}) |
| private isViewCurrent = false; |
| |
| @property({type: String}) |
| _tabState?: TabState; |
| |
| @property({type: Object}) |
| revertedChange?: ChangeInfo; |
| |
| @property({type: String}) |
| scrollCommentId?: UrlEncodedCommentId; |
| |
| @property({ |
| type: Array, |
| computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)', |
| }) |
| resolveWeblinks?: GeneratedWebLink[]; |
| |
| restApiService = appContext.restApiService; |
| |
| private readonly commentsService = appContext.commentsService; |
| |
| private readonly shortcuts = appContext.shortcutsService; |
| |
| private replyDialogResizeObserver?: ResizeObserver; |
| |
| override 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.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', |
| [Shortcut.OPEN_SUBMIT_DIALOG]: '_handleOpenSubmitDialog', |
| [Shortcut.TOGGLE_ATTENTION_SET]: '_handleToggleAttentionSet', |
| }; |
| } |
| |
| disconnected$ = new Subject(); |
| |
| private replyRefitTask?: DelayedTask; |
| |
| private scrollTask?: DelayedTask; |
| |
| private lastStarredTimestamp?: number; |
| |
| override ready() { |
| super.ready(); |
| aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => { |
| this._showChecksTab = b; |
| }); |
| routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => { |
| this.isViewCurrent = view === GerritView.CHANGE; |
| }); |
| drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => { |
| this._diffDrafts = {...drafts}; |
| }); |
| changeComments$ |
| .pipe(takeUntil(this.disconnected$)) |
| .subscribe(changeComments => { |
| this._changeComments = changeComments; |
| }); |
| } |
| |
| constructor() { |
| super(); |
| 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('open-reply-dialog', () => this._openReplyDialog()); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this._throttledToggleChangeStar = throttleWrap<IronKeyboardEvent>(e => |
| this._handleToggleChangeStar(e) |
| ); |
| 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(); |
| }); |
| |
| this.replyDialogResizeObserver = new ResizeObserver(() => |
| this.$.replyOverlay.center() |
| ); |
| this.replyDialogResizeObserver.observe(this.$.replyDialog); |
| |
| 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 |
| ) { |
| this.reporting.error(new Error('Mismatch of headers and content.')); |
| } |
| }) |
| .then(() => this._initActiveTabs(this.params)); |
| |
| this.addEventListener('change-message-deleted', () => fireReload(this)); |
| 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', e => this._onCloseFixPreview(e)); |
| document.addEventListener('visibilitychange', this.handleVisibilityChange); |
| |
| this.addEventListener(EventType.SHOW_PRIMARY_TAB, e => |
| this._setActivePrimaryTab(e) |
| ); |
| this.addEventListener('reload', e => { |
| this.loadData( |
| /* isLocationChange= */ false, |
| /* clearPatchset= */ e.detail && e.detail.clearPatchset |
| ); |
| }); |
| } |
| |
| override disconnectedCallback() { |
| this.disconnected$.next(); |
| document.removeEventListener( |
| 'visibilitychange', |
| this.handleVisibilityChange |
| ); |
| this.replyRefitTask?.cancel(); |
| this.scrollTask?.cancel(); |
| |
| if (this._updateCheckTimerHandle) { |
| this._cancelUpdateCheckTimer(); |
| } |
| super.disconnectedCallback(); |
| } |
| |
| get messagesList(): GrMessagesList | null { |
| return this.shadowRoot!.querySelector<GrMessagesList>('gr-messages-list'); |
| } |
| |
| get threadList(): GrThreadList | null { |
| return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list'); |
| } |
| |
| _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(e: CloseFixPreviewEvent) { |
| if (e.detail.fixApplied) fireReload(this); |
| } |
| |
| _handleToggleDiffMode(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(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; |
| }, |
| src?: string |
| ) { |
| 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) { |
| this.reporting.error(new Error(`tab not found for ${activeDetails}`)); |
| return; |
| } |
| const tabName = tabs[activeIndex].dataset['name']; |
| if (scrollIntoView) { |
| paperTabs.scrollIntoView({block: 'center'}); |
| } |
| if (paperTabs.selected !== activeIndex) { |
| // paperTabs.selected is undefined during rendering |
| if (paperTabs.selected !== undefined) { |
| this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src}); |
| } |
| paperTabs.selected = activeIndex; |
| } |
| 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, |
| }, |
| (e.composedPath()?.[0] as Element | undefined)?.tagName |
| ); |
| 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 = ''; |
| } |
| } |
| this._tabState = e.detail.tabState; |
| } |
| |
| _onPaperTabClick(e: MouseEvent) { |
| let target = e.target as HTMLElement | null; |
| let tabName: string | undefined; |
| // target can be slot child of papertab, so we search for tabName in parents |
| do { |
| tabName = target?.dataset?.['name']; |
| if (tabName) break; |
| target = target?.parentElement as HTMLElement | null; |
| } while (target); |
| |
| if (tabName === PrimaryTab.COMMENT_THREADS) { |
| // Show unresolved threads by default only if they are present |
| const hasUnresolvedThreads = |
| (this._commentThreads ?? []).filter(thread => isUnresolved(thread)) |
| .length > 0; |
| if (hasUnresolvedThreads) this.unresolvedOnly = true; |
| } |
| |
| this.reporting.reportInteraction(Interaction.SHOW_TAB, { |
| tabName, |
| src: 'paper-tab-click', |
| }); |
| } |
| |
| _handleCommitMessageSave(e: EditableContentSaveEvent) { |
| assertIsDefined(this._change, '_change'); |
| 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 |
| ) { |
| if ( |
| !loggedIn || |
| editing || |
| (change && change.status === ChangeStatus.MERGED) || |
| editMode |
| ) { |
| 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}); |
| } |
| |
| /** |
| * Returns `this` as the visibility observer target for the keyboard shortcut |
| * mixin to decide whether shortcuts should be enabled or not. |
| */ |
| _computeObserverTarget() { |
| return this; |
| } |
| |
| _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 |
| ); |
| } |
| |
| _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 |
| ); |
| } |
| |
| _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(); |
| } |
| |
| _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(Timing.SEND_REPLY); |
| }, |
| {once: true} |
| ); |
| this.$.replyOverlay.cancel(); |
| fireReload(this); |
| } |
| |
| _handleReplyCancel() { |
| this.$.replyOverlay.cancel(); |
| } |
| |
| _handleReplyAutogrow() { |
| // If the textarea resizes, we need to re-fit the overlay. |
| this.replyRefitTask = debounce( |
| this.replyRefitTask, |
| () => 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); |
| } |
| |
| _setShownFiles(e: CustomEvent<{length: number}>) { |
| this._shownFileCount = e.detail.length; |
| } |
| |
| _expandAllDiffs() { |
| this.$.fileList.expandAllDiffs(); |
| } |
| |
| _collapseAllDiffs() { |
| this.$.fileList.collapseAllDiffs(); |
| } |
| |
| /** |
| * ChangeView is never re-used for different changes. It is safer and simpler |
| * to just re-create another change view when the user switches to a new |
| * change page. Thus we need a reliable way to detect that the change view |
| * does not match the current change number anymore. |
| * |
| * If this method returns true, then the change view should not do anything |
| * anymore. The app element makes sure that an obsolete change view is not |
| * shown anymore, so if the change view is still and doing some update to |
| * itself, then that is not dangerous. But for example it should not call |
| * navigateToChange() anymore. That would very likely cause erroneous |
| * behavior. |
| */ |
| private isChangeObsolete() { |
| // While this._changeNum is undefined the change view is fresh and has just |
| // not updated it to params.changeNum yet. Not obsolete in that case. |
| if (this._changeNum === undefined) return false; |
| // this.params reflects the current state of the URL. If this._changeNum |
| // does not match it anymore, then this view must be considered obsolete. |
| return this._changeNum !== this.params?.changeNum; |
| } |
| |
| _paramsChanged(value: AppElementChangeViewParams) { |
| if (value.view !== GerritView.CHANGE) { |
| this._initialLoadComplete = false; |
| querySelectorAll(this, 'gr-overlay').forEach(overlay => |
| (overlay as GrOverlay).close() |
| ); |
| return; |
| } |
| |
| if (this.isChangeObsolete()) { |
| // Tell the app element that we are not going to handle the new change |
| // number and that they have to create a new change view. |
| fireEvent(this, EventType.RECREATE_CHANGE_VIEW); |
| return; |
| } |
| |
| if (value.changeNum && value.project) { |
| this.restApiService.setInProjectLookup(value.changeNum, value.project); |
| } |
| |
| if (value.basePatchNum === undefined) |
| value.basePatchNum = ParentPatchSetNum; |
| |
| const patchChanged = |
| this._patchRange && |
| value.patchNum !== undefined && |
| (this._patchRange.patchNum !== value.patchNum || |
| this._patchRange.basePatchNum !== value.basePatchNum); |
| |
| let rightPatchNumChanged = |
| this._patchRange && |
| value.patchNum !== undefined && |
| this._patchRange.patchNum !== value.patchNum; |
| |
| const patchRange: ChangeViewPatchRange = { |
| patchNum: value.patchNum, |
| basePatchNum: value.basePatchNum, |
| }; |
| |
| this.$.fileList.collapseAllDiffs(); |
| this._patchRange = patchRange; |
| this.scrollCommentId = value.commentId; |
| |
| const patchKnown = |
| !patchRange.patchNum || |
| (this._allPatchSets ?? []).some(ps => ps.num === patchRange.patchNum); |
| |
| // If the change has already been loaded and the parameter change is only |
| // in the patch range, then don't do a full reload. |
| if (this._changeNum !== undefined && patchChanged && patchKnown) { |
| 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.loadData(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; |
| } else if (params && 'commentId' in params) { |
| primaryTab = PrimaryTab.COMMENT_THREADS; |
| } |
| this._setActivePrimaryTab( |
| new CustomEvent('initActiveTab', { |
| detail: { |
| tab: primaryTab, |
| }, |
| }) |
| ); |
| } |
| |
| _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(); |
| |
| setTimeout(() => { |
| 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}>) { |
| assertIsDefined(this._change, '_change'); |
| if (!this._patchRange) |
| throw new Error('missing required _patchRange property'); |
| const hash = 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(PREFIX) && this.messagesList) { |
| this.messagesList.scrollToMessage(hash.substr(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); |
| this.set('viewState.showReplyDialog', false); |
| } |
| }); |
| } |
| |
| _maybeShowDownloadDialog() { |
| if (this.viewState.showDownloadDialog) { |
| this._handleOpenDownloadDialog(); |
| this.set('viewState.showDownloadDialog', false); |
| } |
| } |
| |
| _resetFileListViewState() { |
| this.set('viewState.selectedFileIndex', 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 === DefaultBase.FIRST_PARENT; |
| |
| if (parentCount > 1 && preferFirst && !patchRange.patchNum) { |
| return -1; |
| } |
| |
| return 'PARENT'; |
| } |
| |
| _computeChangeUrl(change: ChangeInfo) { |
| return GerritNav.getUrlForChange(change); |
| } |
| |
| _computeReplyButtonLabel( |
| drafts?: {[path: string]: UIDraft[]}, |
| canStartReview?: boolean |
| ) { |
| if (drafts === undefined || canStartReview === undefined) { |
| return 'Reply'; |
| } |
| |
| 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: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(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: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._handleOpenDownloadDialog(); |
| } |
| |
| _handleEditTopic(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this.$.metadata.editTopic(); |
| } |
| |
| _handleOpenSubmitDialog(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || !this._submitEnabled) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this.$.actions.showSubmitDialog(); |
| } |
| |
| _handleToggleAttentionSet(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| if (!this._change || !this._account?._account_id) return; |
| if (!this._loggedIn || !isInvolved(this._change, this._account)) return; |
| if (!this._change.attention_set) this._change.attention_set = {}; |
| if (hasAttention(this._account, this._change)) { |
| const reason = getRemovedByReason(this._account, this._serverConfig); |
| if (this._change.attention_set) |
| delete this._change.attention_set[this._account._account_id]; |
| fireAlert(this, 'Removing you from the attention set ...'); |
| this.restApiService |
| .removeFromAttentionSet( |
| this._change._number, |
| this._account._account_id, |
| reason |
| ) |
| .then(() => { |
| fireEvent(this, 'hide-alert'); |
| }); |
| } else { |
| const reason = getAddedByReason(this._account, this._serverConfig); |
| fireAlert(this, 'Adding you to the attention set ...'); |
| this._change.attention_set[this._account._account_id!] = { |
| account: this._account, |
| reason, |
| reason_account: this._account, |
| }; |
| this.restApiService |
| .addToAttentionSet( |
| this._change._number, |
| this._account._account_id, |
| reason |
| ) |
| .then(() => { |
| fireEvent(this, 'hide-alert'); |
| }); |
| } |
| this._change = {...this._change}; |
| } |
| |
| _handleDiffAgainstBase(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| assertIsDefined(this._change, '_change'); |
| 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: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| assertIsDefined(this._change, '_change'); |
| 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: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| assertIsDefined(this._change, '_change'); |
| 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: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| assertIsDefined(this._change, '_change'); |
| 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 as BasePatchSetNum |
| ); |
| } |
| |
| _handleDiffBaseAgainstLatest(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| assertIsDefined(this._change, '_change'); |
| 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: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e)) { |
| return; |
| } |
| e.preventDefault(); |
| fireReload(this, true); |
| } |
| |
| _handleToggleChangeStar(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) { |
| return; |
| } |
| e.preventDefault(); |
| this.$.changeStar.toggleStar(); |
| } |
| |
| _handleUpToDashboard(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| this._determinePageBack(); |
| } |
| |
| _handleExpandAllMessages(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| if (this.messagesList) { |
| this.messagesList.handleExpandCollapse(true); |
| } |
| } |
| |
| _handleCollapseAllMessages(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(e) || this.modifierPressed(e)) { |
| return; |
| } |
| |
| e.preventDefault(); |
| if (this.messagesList) { |
| this.messagesList.handleExpandCollapse(false); |
| } |
| } |
| |
| _handleOpenDiffPrefsShortcut(e: IronKeyboardEvent) { |
| if (this.shortcuts.shouldSuppress(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 |
| ) { |
| fireReload(this); |
| 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) { |
| if (!this._change) return; |
| this.$.replyOverlay.open().finally(() => { |
| // the following code should be executed no matter open succeed or not |
| this._resetReplyOverlayFocusStops(); |
| this.$.replyDialog.open(section); |
| }); |
| fireDialogChange(this, {opened: true}); |
| this._changeViewAriaHidden = true; |
| } |
| |
| _handleGetChangeDetailError(response?: Response | null) { |
| firePageError(response); |
| } |
| |
| _getLoggedIn() { |
| return this.restApiService.getLoggedIn(); |
| } |
| |
| _getServerConfig() { |
| return this.restApiService.getConfig(); |
| } |
| |
| _getProjectConfig() { |
| assertIsDefined(this._change, '_change'); |
| 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 && |
| this._patchRange?.patchNum === EditPatchSetNum && |
| changeIsOpen(change) |
| ) { |
| fireAlert(this, 'Change edit not found. Please create a change edit.'); |
| fireReload(this, true); |
| return; |
| } |
| |
| if ( |
| !edit && |
| (changeIsMerged(change) || changeIsAbandoned(change)) && |
| this._editMode |
| ) { |
| fireAlert( |
| this, |
| 'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.' |
| ); |
| fireReload(this, true); |
| return; |
| } |
| |
| 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; |
| } |
| } |
| } |
| |
| computeRevertSubmitted(change?: ChangeInfo | ParsedChangeInfo) { |
| if (!change?.messages) return; |
| Promise.all( |
| getRevertCreatedChangeIds(change.messages).map(changeId => |
| this.restApiService.getChange(changeId) |
| ) |
| ).then(changes => { |
| // if a change is deleted then getChanges returns null for that changeId |
| changes = changes.filter( |
| change => change && change.status !== ChangeStatus.ABANDONED |
| ); |
| if (!changes.length) return; |
| const submittedRevert = changes.find( |
| change => change?.status === ChangeStatus.MERGED |
| ); |
| if (!this._changeStatuses) return; |
| if (submittedRevert) { |
| this.revertedChange = submittedRevert; |
| this.push('_changeStatuses', ChangeStates.REVERT_SUBMITTED); |
| } else { |
| if (changes[0]) this.revertedChange = changes[0]; |
| this.push('_changeStatuses', ChangeStates.REVERT_CREATED); |
| } |
| }); |
| } |
| |
| _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.changeService.updateChange(change); |
| this._change = change; |
| this.computeRevertSubmitted(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 true; |
| } |
| ); |
| } |
| |
| _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'); |
| |
| // We only call _getEdit if the patchset number is an edit. |
| // We have to do this to ensure we can tell if an edit |
| // exists or not. |
| // This safely works even if a edit does not exist. |
| if (this._patchRange!.patchNum! === EditPatchSetNum) { |
| return this._getEdit().then(edit => { |
| if (!edit) { |
| return Promise.resolve(); |
| } |
| |
| return this._getChangeCommitInfo(); |
| }); |
| } |
| |
| return this._getChangeCommitInfo(); |
| } |
| |
| _getChangeCommitInfo() { |
| return this.restApiService |
| .getChangeCommitInfo(this._changeNum!, this._patchRange!.patchNum!) |
| .then(commitInfo => { |
| this._commitInfo = commitInfo; |
| }); |
| } |
| |
| /** |
| * 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._draftCommentThreads = undefined; |
| this._robotCommentThreads = undefined; |
| if (!this._changeNum) |
| throw new Error('missing required changeNum property'); |
| |
| this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum); |
| } |
| |
| @observe('_changeComments') |
| changeCommentsChanged(comments?: ChangeComments) { |
| if (!comments) return; |
| this._changeComments = comments; |
| 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. |
| */ |
| loadData(isLocationChange?: boolean, clearPatchset?: boolean): Promise<void> { |
| if (this.isChangeObsolete()) return Promise.resolve(); |
| if (clearPatchset && this._change) { |
| GerritNav.navigateToChange(this._change); |
| return Promise.resolve(); |
| } |
| this._loading = true; |
| this.reporting.time(Timing.CHANGE_RELOAD); |
| this.reporting.time(Timing.CHANGE_DATA); |
| |
| // Array to house all promises related to data requests. |
| const allDataPromises: Promise<unknown>[] = []; |
| |
| // Resolves when the change detail and the edit patch set (if available) |
| // are loaded. |
| const detailCompletes = this._getChangeDetail(); |
| allDataPromises.push(detailCompletes); |
| |
| // Resolves when the loading flag is set to false, meaning that some |
| // change content may start appearing. |
| const loadingFlagSet = detailCompletes |
| .then(() => { |
| this._loading = false; |
| fireEvent(this, 'change-details-loaded'); |
| }) |
| .then(() => { |
| this.reporting.timeEnd(Timing.CHANGE_RELOAD); |
| if (isLocationChange) { |
| this.reporting.changeDisplayed({ |
| isOwner: isOwner(this._change, this._account), |
| isReviewer: isReviewer(this._change, this._account), |
| isCc: isCc(this._change, this._account), |
| }); |
| } |
| }); |
| |
| // Resolves when the project config has successfully loaded. |
| const projectConfigLoaded = detailCompletes.then(success => { |
| if (!success) return Promise.resolve(); |
| return this._getProjectConfig(); |
| }); |
| allDataPromises.push(projectConfigLoaded); |
| |
| this._reloadComments(); |
| |
| 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, |
| ]); |
| |
| // _getChangeDetail triggers reload of change actions already. |
| |
| // The core data is loaded when mergeability is known. |
| coreDataPromise = detailAndPatchResourcesLoaded.then(() => |
| this._getMergeability() |
| ); |
| } 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); |
| |
| // Core data is loaded when mergeability has been loaded. |
| coreDataPromise = loadingFlagSet.then(() => this._getMergeability()); |
| } |
| |
| allDataPromises.push(coreDataPromise); |
| |
| if (isLocationChange) { |
| this._editingCommitMessage = false; |
| const relatedChangesLoaded = coreDataPromise.then(() => { |
| let relatedChangesPromise: |
| | Promise<RelatedChangesInfo | undefined> |
| | undefined; |
| const patchNum = this._computeLatestPatchNum(this._allPatchSets); |
| if (this._change && patchNum) { |
| relatedChangesPromise = this.restApiService |
| .getRelatedChanges(this._change._number, patchNum) |
| .then(response => { |
| if (this._change && response) { |
| this.hasParent = this._calculateHasParent( |
| this._change.change_id, |
| response.changes |
| ); |
| } |
| return response; |
| }); |
| } |
| // TODO: use returned Promise |
| this.getRelatedChangesList()?.reload(relatedChangesPromise); |
| }); |
| allDataPromises.push(relatedChangesLoaded); |
| } |
| |
| Promise.all(allDataPromises).then(() => { |
| // Loading of commments data is no longer part of this reporting |
| this.reporting.timeEnd(Timing.CHANGE_DATA); |
| if (isLocationChange) { |
| this.reporting.changeFullyLoaded(); |
| } |
| }); |
| |
| return coreDataPromise; |
| } |
| |
| /** |
| * Determines whether or not the given change has a parent change. If there |
| * is a relation chain, and the change id is not the last item of the |
| * relation chain, there is a parent. |
| */ |
| _calculateHasParent( |
| currentChangeId: ChangeId, |
| relatedChanges: RelatedChangeAndCommitInfo[] |
| ) { |
| return ( |
| relatedChanges.length > 0 && |
| relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId |
| ); |
| } |
| |
| /** |
| * Kicks off requests for resources that rely on the patch range |
| * (`this._patchRange`) being defined. |
| */ |
| _reloadPatchNumDependentResources(rightPatchNumChanged?: boolean) { |
| assertIsDefined(this._changeNum, '_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(): Promise<void> { |
| 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')); |
| } |
| |
| // If mergeable bit was already returned in detail REST endpoint, use it. |
| if (this._change.mergeable !== undefined) { |
| this._mergeable = this._change.mergeable; |
| return Promise.resolve(); |
| } |
| |
| this._mergeable = null; |
| return this.restApiService |
| .getMergeable(this._changeNum) |
| .then(mergableInfo => { |
| if (mergableInfo) { |
| this._mergeable = mergableInfo.mergeable; |
| } |
| }); |
| } |
| |
| _computeResolveWeblinks( |
| change?: ChangeInfo, |
| commitInfo?: CommitInfo, |
| config?: ServerInfo |
| ) { |
| if (!change || !commitInfo || !config) { |
| return []; |
| } |
| return GerritNav.getResolveConflictsWeblinks( |
| change.project, |
| commitInfo.commit, |
| { |
| weblinks: commitInfo.resolve_conflicts_web_links, |
| config, |
| } |
| ); |
| } |
| |
| _computeCanStartReview(change: ChangeInfo): boolean { |
| return !!( |
| change.actions && |
| change.actions.ready && |
| change.actions.ready.enabled |
| ); |
| } |
| |
| _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) { |
| return `Change ${changeNum}`; |
| } |
| |
| /** |
| * Returns the text to be copied when |
| * click the copy icon next to change subject |
| */ |
| _computeCopyTextForTitle(change: ChangeInfo): string { |
| return ( |
| `${change._number}: ${change.subject} | ` + |
| `${location.protocol}//${location.host}` + |
| `${this._computeChangeUrl(change)}` |
| ); |
| } |
| |
| _computeCommitCollapsible(commitMessage?: string) { |
| if (!commitMessage) { |
| return false; |
| } |
| return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE; |
| } |
| |
| _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 = window.setTimeout(() => { |
| if (!this.isViewCurrent) { |
| this._startUpdateCheckTimer(); |
| return; |
| } |
| assertIsDefined(this._change, '_change'); |
| const change = this._change; |
| this.changeService.fetchChangeUpdates(change).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.isViewCurrent |
| ) { |
| 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: () => fireReload(this, true), |
| }, |
| composed: true, |
| bubbles: true, |
| }) |
| ); |
| }); |
| }, this._serverConfig.change.update_delay * 1000); |
| } |
| |
| _cancelUpdateCheckTimer() { |
| if (this._updateCheckTimerHandle) { |
| window.clearTimeout(this._updateCheckTimerHandle); |
| } |
| this._updateCheckTimerHandle = null; |
| } |
| |
| private readonly 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'); |
| assertIsDefined(this._change, '_change'); |
| 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; |
| } |
| assertIsDefined(this._change, '_change'); |
| |
| 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() { |
| assertIsDefined(this._change, '_change'); |
| 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}>) { |
| if (e.detail.starred) { |
| this.reporting.reportInteraction('change-starred-from-change-view'); |
| this.lastStarredTimestamp = Date.now(); |
| } else { |
| if ( |
| this.lastStarredTimestamp && |
| Date.now() - this.lastStarredTimestamp < ACCIDENTAL_STARRING_LIMIT_MS |
| ) { |
| this.reporting.reportInteraction('change-accidentally-starred'); |
| } |
| } |
| this.restApiService.saveChangeStarred( |
| e.detail.change._number, |
| e.detail.starred |
| ); |
| } |
| |
| _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo): RevisionInfoClass { |
| 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[]): boolean { |
| return hasEditBasedOnCurrentPatchSet(allPatchSets); |
| } |
| |
| /** |
| * Wrapper for using in the element template and computed properties |
| */ |
| _hasEditPatchsetLoaded( |
| patchRangeRecord: PolymerDeepPropertyChange< |
| ChangeViewPatchRange, |
| ChangeViewPatchRange |
| > |
| ): boolean { |
| 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' |
| ); |
| } |
| |
| createTitle(shortcutName: Shortcut, section: ShortcutSection) { |
| return this.shortcuts.createTitle(shortcutName, section); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-change-view': GrChangeView; |
| } |
| } |