| /** |
| * @license |
| * Copyright 2015 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {BehaviorSubject} from 'rxjs'; |
| import '../gr-copy-links/gr-copy-links'; |
| import '@polymer/paper-tabs/paper-tabs'; |
| import '../../../styles/gr-a11y-styles'; |
| import '../../../styles/gr-paper-styles'; |
| import '../../../styles/shared-styles'; |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; |
| import '../../plugins/gr-endpoint-param/gr-endpoint-param'; |
| 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-formatted-text/gr-formatted-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 '../gr-commit-info/gr-commit-info'; |
| import '../gr-download-dialog/gr-download-dialog'; |
| import '../gr-file-list-header/gr-file-list-header'; |
| import '../gr-file-list/gr-file-list'; |
| 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 {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star'; |
| import {flush} from '@polymer/polymer/lib/legacy/polymer.dom'; |
| import {GrEditConstants} from '../../edit/gr-edit-constants'; |
| import {pluralize} from '../../../utils/string-util'; |
| import {querySelectorAll, whenVisible} from '../../../utils/dom-util'; |
| import {navigationToken} 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 { |
| ChangeStatus, |
| DefaultBase, |
| Tab, |
| DiffViewMode, |
| } from '../../../constants/constants'; |
| import {getAppContext} from '../../../services/app-context'; |
| import { |
| computeAllPatchSets, |
| computeLatestPatchNum, |
| findEdit, |
| findEditParentRevision, |
| PatchSet, |
| } from '../../../utils/patch-set-util'; |
| import { |
| changeIsAbandoned, |
| changeIsMerged, |
| changeIsOpen, |
| changeStatuses, |
| isInvolved, |
| roleDetails, |
| } from '../../../utils/change-util'; |
| import {EventType as PluginEventType} from '../../../api/plugin'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| 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, |
| BasePatchSetNum, |
| ChangeId, |
| ChangeInfo, |
| CommitId, |
| CommitInfo, |
| ConfigInfo, |
| DetailedLabelInfo, |
| EDIT, |
| LabelNameToInfoMap, |
| NumericChangeId, |
| PARENT, |
| PatchRange, |
| PatchSetNum, |
| PatchSetNumber, |
| PreferencesInfo, |
| QuickLabelInfo, |
| RelatedChangeAndCommitInfo, |
| RelatedChangesInfo, |
| RevisionInfo, |
| RevisionPatchSetNum, |
| ServerInfo, |
| UrlEncodedCommentId, |
| } from '../../../types/common'; |
| 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 {assertIsDefined, assert, queryAll} from '../../../utils/common-util'; |
| import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls'; |
| import { |
| CommentThread, |
| isRobot, |
| isUnresolved, |
| DraftInfo, |
| } from '../../../utils/comment-util'; |
| import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs'; |
| import {GrFileList} from '../gr-file-list/gr-file-list'; |
| import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types'; |
| import { |
| CloseFixPreviewEvent, |
| EditableContentSaveEvent, |
| EventType, |
| OpenFixPreviewEvent, |
| ShowAlertEventDetail, |
| SwitchTabEvent, |
| TabState, |
| ValueChangedEvent, |
| } 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, |
| fireReload, |
| fireTitleChange, |
| } from '../../../utils/event-util'; |
| import {GerritView} from '../../../services/router/router-model'; |
| import { |
| debounce, |
| DelayedTask, |
| throttleWrap, |
| until, |
| } from '../../../utils/async-util'; |
| import {Interaction, Timing, Execution} from '../../../constants/reporting'; |
| import {ChangeStates} from '../../shared/gr-change-status/gr-change-status'; |
| import {getRevertCreatedChangeIds} from '../../../utils/message-util'; |
| import { |
| getAddedByReason, |
| getRemovedByReason, |
| hasAttention, |
| } from '../../../utils/attention-set-util'; |
| import { |
| Shortcut, |
| ShortcutSection, |
| shortcutsServiceToken, |
| } from '../../../services/shortcuts/shortcuts-service'; |
| import {LoadingStatus} from '../../../models/change/change-model'; |
| import {commentsModelToken} from '../../../models/comments/comments-model'; |
| import {resolve} from '../../../models/dependency'; |
| import {checksModelToken} from '../../../models/checks/checks-model'; |
| import {changeModelToken} from '../../../models/change/change-model'; |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; |
| import {a11yStyles} from '../../../styles/gr-a11y-styles'; |
| import {paperStyles} from '../../../styles/gr-paper-styles'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {ifDefined} from 'lit/directives/if-defined.js'; |
| import {when} from 'lit/directives/when.js'; |
| import {ShortcutController} from '../../lit/shortcut-controller'; |
| import {FilesExpandedState} from '../gr-file-list-constants'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {configModelToken} from '../../../models/config/config-model'; |
| import {filesModelToken} from '../../../models/change/files-model'; |
| import {getBaseUrl, prependOrigin} from '../../../utils/url-util'; |
| import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links'; |
| import { |
| changeViewModelToken, |
| ChangeViewState, |
| createChangeUrl, |
| } from '../../../models/views/change'; |
| import {rootUrl} from '../../../utils/url-util'; |
| import {createEditUrl} from '../../../models/views/edit'; |
| |
| 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 = 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 type ChangeViewPatchRange = Partial<PatchRange>; |
| |
| @customElement('gr-change-view') |
| export class GrChangeView extends LitElement { |
| /** |
| * 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 |
| */ |
| |
| @query('#applyFixDialog') applyFixDialog?: GrApplyFixDialog; |
| |
| @query('#fileList') fileList?: GrFileList; |
| |
| @query('#fileListHeader') fileListHeader?: GrFileListHeader; |
| |
| @query('#commitMessageEditor') commitMessageEditor?: GrEditableContent; |
| |
| @query('#includedInOverlay') includedInOverlay?: GrOverlay; |
| |
| @query('#includedInDialog') includedInDialog?: GrIncludedInDialog; |
| |
| @query('#downloadOverlay') downloadOverlay?: GrOverlay; |
| |
| @query('#downloadDialog') downloadDialog?: GrDownloadDialog; |
| |
| @query('#replyOverlay') replyOverlay?: GrOverlay; |
| |
| @query('#replyDialog') replyDialog?: GrReplyDialog; |
| |
| @query('#mainContent') mainContent?: HTMLDivElement; |
| |
| @query('#changeStar') changeStar?: GrChangeStar; |
| |
| @query('#actions') actions?: GrChangeActions; |
| |
| @query('#commitMessage') commitMessage?: HTMLDivElement; |
| |
| @query('#commitAndRelated') commitAndRelated?: HTMLDivElement; |
| |
| @query('#metadata') metadata?: GrChangeMetadata; |
| |
| @query('#mainChangeInfo') mainChangeInfo?: HTMLDivElement; |
| |
| @query('#replyBtn') replyBtn?: GrButton; |
| |
| @query('#tabs') tabs?: PaperTabsElement; |
| |
| @query('gr-messages-list') messagesList?: GrMessagesList; |
| |
| @query('gr-thread-list') threadList?: GrThreadList; |
| |
| @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks; |
| |
| private _viewState?: ChangeViewState; |
| |
| @property({type: Object}) |
| get viewState() { |
| return this._viewState; |
| } |
| |
| set viewState(viewState: ChangeViewState | undefined) { |
| if (this._viewState === viewState) return; |
| const oldViewState = this._viewState; |
| this._viewState = viewState; |
| this.viewStateChanged(); |
| this.requestUpdate('viewState', oldViewState); |
| } |
| |
| @property({type: String}) |
| backPage?: string; |
| |
| @state() |
| private hasParent?: boolean; |
| |
| // Private but used in tests. |
| @state() |
| commentThreads?: CommentThread[]; |
| |
| // Don't use, use serverConfig instead. |
| private _serverConfig?: ServerInfo; |
| |
| // Private but used in tests. |
| @state() |
| get serverConfig() { |
| return this._serverConfig; |
| } |
| |
| set serverConfig(serverConfig: ServerInfo | undefined) { |
| if (this._serverConfig === serverConfig) return; |
| const oldServerConfig = this._serverConfig; |
| this._serverConfig = serverConfig; |
| this.startUpdateCheckTimer(); |
| this.requestUpdate('serverConfig', oldServerConfig); |
| } |
| |
| @state() |
| private account?: AccountDetailInfo; |
| |
| // Private but used in tests. |
| @state() |
| prefs?: PreferencesInfo; |
| |
| canStartReview() { |
| return !!( |
| this.change && |
| this.change.actions && |
| this.change.actions.ready && |
| this.change.actions.ready.enabled |
| ); |
| } |
| |
| // Use change getter/setter instead. |
| private _change?: ParsedChangeInfo; |
| |
| @state() |
| get change() { |
| return this._change; |
| } |
| |
| set change(change: ParsedChangeInfo | undefined) { |
| if (this._change === change) return; |
| const oldChange = this._change; |
| this._change = change; |
| this.changeChanged(oldChange); |
| this.requestUpdate('change', oldChange); |
| } |
| |
| // Private but used in tests. |
| @state() |
| commitInfo?: CommitInfo; |
| |
| // Private but used in tests. |
| @state() |
| changeNum?: NumericChangeId; |
| |
| // Private but used in tests. |
| @state() |
| diffDrafts?: {[path: string]: DraftInfo[]} = {}; |
| |
| @state() |
| private editingCommitMessage = false; |
| |
| @state() |
| private latestCommitMessage: string | null = ''; |
| |
| // Use patchRange getter/setter. |
| private _patchRange?: ChangeViewPatchRange; |
| |
| // Private but used in tests. |
| @state() |
| get patchRange() { |
| return this._patchRange; |
| } |
| |
| set patchRange(patchRange: ChangeViewPatchRange | undefined) { |
| if (this._patchRange === patchRange) return; |
| const oldPatchRange = this._patchRange; |
| this._patchRange = patchRange; |
| this.patchNumChanged(); |
| this.requestUpdate('patchRange', oldPatchRange); |
| } |
| |
| // Private but used in tests. |
| @state() |
| selectedRevision?: RevisionInfo | EditRevisionInfo; |
| |
| @state() |
| get changeIdCommitMessageError() { |
| return this.computeChangeIdCommitMessageError( |
| this.latestCommitMessage, |
| this.change |
| ); |
| } |
| |
| /** |
| * <gr-change-actions> populates this via two-way data binding. |
| * Private but used in tests. |
| */ |
| @state() |
| currentRevisionActions?: ActionNameToActionInfoMap = {}; |
| |
| @state() |
| private allPatchSets?: PatchSet[]; |
| |
| // Private but used in tests. |
| @state() |
| loggedIn = false; |
| |
| // Private but used in tests. |
| @state() |
| loading?: boolean; |
| |
| @state() |
| private projectConfig?: ConfigInfo; |
| |
| @state() |
| private shownFileCount?: number; |
| |
| // Private but used in tests. |
| @state() |
| initialLoadComplete = false; |
| |
| // Private but used in tests. |
| @state() |
| replyDisabled = true; |
| |
| // Private but used in tests. |
| @state() |
| changeStatuses: ChangeStates[] = []; |
| |
| @state() |
| private updateCheckTimerHandle?: number | null; |
| |
| // Private but used in tests. |
| getEditMode() { |
| if (!this.patchRange || !this.viewState) { |
| return false; |
| } |
| |
| if (this.viewState.edit) { |
| return true; |
| } |
| |
| return this.patchRange.patchNum === EDIT; |
| } |
| |
| isSubmitEnabled(): boolean { |
| return !!( |
| this.currentRevisionActions && |
| this.currentRevisionActions.submit && |
| this.currentRevisionActions.submit.enabled |
| ); |
| } |
| |
| // Private but used in tests. |
| @state() |
| mergeable: boolean | null = null; |
| |
| /** |
| * Plugins can provide (multiple) tabs. For each plugin tab we render an |
| * endpoint for the header. If the plugin tab is active, then we also render |
| * an endpoint for the content. |
| * |
| * This is the list of endpoint names for the headers. The header name that |
| * the user sees is an implementation detail of the plugin that we don't know. |
| */ |
| // Private but used in tests. |
| @state() |
| pluginTabsHeaderEndpoints: string[] = []; |
| |
| /** |
| * Plugins can provide (multiple) tabs. For each plugin tab we render an |
| * endpoint for the header. If the plugin tab is active, then we also render |
| * an endpoint for the content. |
| * |
| * This is the list of endpoint names for the content. |
| */ |
| @state() |
| private pluginTabsContentEndpoints: string[] = []; |
| |
| @state() |
| private 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 |
| @state() |
| private changeViewAriaHidden = false; |
| |
| /** |
| * This can be a string only for plugin provided tabs. |
| */ |
| // visible for testing |
| @state() |
| activeTab: Tab | string = Tab.FILES; |
| |
| @property({type: Boolean}) |
| unresolvedOnly = true; |
| |
| @state() |
| private showAllRobotComments = false; |
| |
| @state() |
| private showRobotCommentsButton = false; |
| |
| @state() |
| private draftCount = 0; |
| |
| private throttledToggleChangeStar?: (e: KeyboardEvent) => void; |
| |
| @state() |
| private showChecksTab = false; |
| |
| // visible for testing |
| @state() |
| showFindingsTab = false; |
| |
| @state() |
| private isViewCurrent = false; |
| |
| @state() |
| private tabState?: TabState; |
| |
| @state() |
| private revertedChange?: ChangeInfo; |
| |
| // Private but used in tests. |
| @state() |
| scrollCommentId?: UrlEncodedCommentId; |
| |
| /** Just reflects the `opened` prop of the overlay. */ |
| @state() |
| private replyOverlayOpened = false; |
| |
| // Accessed in tests. |
| readonly reporting = getAppContext().reportingService; |
| |
| readonly jsAPI = getAppContext().jsApiService; |
| |
| private readonly getChecksModel = resolve(this, checksModelToken); |
| |
| readonly restApiService = getAppContext().restApiService; |
| |
| // Private but used in tests. |
| readonly userModel = getAppContext().userModel; |
| |
| // Private but used in tests. |
| readonly getChangeModel = resolve(this, changeModelToken); |
| |
| private readonly routerModel = getAppContext().routerModel; |
| |
| // Private but used in tests. |
| readonly getCommentsModel = resolve(this, commentsModelToken); |
| |
| private readonly getConfigModel = resolve(this, configModelToken); |
| |
| private readonly getFilesModel = resolve(this, filesModelToken); |
| |
| private readonly getViewModel = resolve(this, changeViewModelToken); |
| |
| private readonly getShortcutsService = resolve(this, shortcutsServiceToken); |
| |
| private replyRefitTask?: DelayedTask; |
| |
| private scrollTask?: DelayedTask; |
| |
| private lastStarredTimestamp?: number; |
| |
| private diffViewMode?: DiffViewMode; |
| |
| /** |
| * If the user comes back to the change page we want to remember the scroll |
| * position when we re-render the page as is. |
| */ |
| private scrollPosition?: number; |
| |
| private connected$ = new BehaviorSubject(false); |
| |
| /** |
| * For `connectedCallback()` to distinguish between connecting to the DOM for |
| * the first time or if just re-connecting. |
| */ |
| private isFirstConnection = true; |
| |
| /** Simply reflects the router-model value. */ |
| // visible for testing |
| routerPatchNum?: RevisionPatchSetNum; |
| |
| private readonly shortcutsController = new ShortcutController(this); |
| |
| private readonly getNavigation = resolve(this, navigationToken); |
| |
| constructor() { |
| super(); |
| this.setupListeners(); |
| this.setupShortcuts(); |
| this.setupSubscriptions(); |
| } |
| |
| private setupListeners() { |
| 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()); |
| 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)); |
| |
| this.addEventListener(EventType.SHOW_TAB, e => this.setActiveTab(e)); |
| this.addEventListener('reload', e => { |
| this.loadData( |
| /* isLocationChange= */ false, |
| /* clearPatchset= */ e.detail && e.detail.clearPatchset |
| ); |
| }); |
| } |
| |
| private setupShortcuts() { |
| // TODO: Do we still need docOnly bindings? |
| this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly |
| this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly |
| this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () => |
| fireReload(this, true) |
| ); |
| this.shortcutsController.addAbstract(Shortcut.OPEN_REPLY_DIALOG, () => |
| this.handleOpenReplyDialog() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.OPEN_DOWNLOAD_DIALOG, () => |
| this.handleOpenDownloadDialog() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.TOGGLE_DIFF_MODE, () => |
| this.handleToggleDiffMode() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, e => { |
| if (this.throttledToggleChangeStar) { |
| this.throttledToggleChangeStar(e); |
| } |
| }); |
| this.shortcutsController.addAbstract(Shortcut.UP_TO_DASHBOARD, () => |
| this.determinePageBack() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.EXPAND_ALL_MESSAGES, () => |
| this.handleExpandAllMessages() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.COLLAPSE_ALL_MESSAGES, () => |
| this.handleCollapseAllMessages() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.OPEN_DIFF_PREFS, () => |
| this.handleOpenDiffPrefsShortcut() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.EDIT_TOPIC, () => { |
| assertIsDefined(this.metadata); |
| this.metadata.editTopic(); |
| }); |
| this.shortcutsController.addAbstract(Shortcut.DIFF_AGAINST_BASE, () => |
| this.handleDiffAgainstBase() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.DIFF_AGAINST_LATEST, () => |
| this.handleDiffAgainstLatest() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.DIFF_BASE_AGAINST_LEFT, () => |
| this.handleDiffBaseAgainstLeft() |
| ); |
| this.shortcutsController.addAbstract( |
| Shortcut.DIFF_RIGHT_AGAINST_LATEST, |
| () => this.handleDiffRightAgainstLatest() |
| ); |
| this.shortcutsController.addAbstract( |
| Shortcut.DIFF_BASE_AGAINST_LATEST, |
| () => this.handleDiffBaseAgainstLatest() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.OPEN_SUBMIT_DIALOG, () => |
| this.handleOpenSubmitDialog() |
| ); |
| this.shortcutsController.addAbstract(Shortcut.TOGGLE_ATTENTION_SET, () => |
| this.handleToggleAttentionSet() |
| ); |
| this.shortcutsController.addAbstract( |
| Shortcut.OPEN_COPY_LINKS_DROPDOWN, |
| () => this.copyLinksDropdown?.openDropdown() |
| ); |
| } |
| |
| private setupSubscriptions() { |
| subscribe( |
| this, |
| () => this.getViewModel().state$, |
| s => (this.viewState = s) |
| ); |
| subscribe( |
| this, |
| () => this.getViewModel().tab$, |
| t => (this.activeTab = t ?? Tab.FILES) |
| ); |
| subscribe( |
| this, |
| () => this.getChecksModel().aPluginHasRegistered$, |
| b => { |
| this.showChecksTab = b; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().robotCommentCount$, |
| count => { |
| this.showFindingsTab = count > 0; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.routerModel.routerView$, |
| view => { |
| this.isViewCurrent = view === GerritView.CHANGE; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.routerModel.routerPatchNum$, |
| patchNum => { |
| this.routerPatchNum = patchNum; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().drafts$, |
| drafts => { |
| this.diffDrafts = {...drafts}; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.userModel.preferenceDiffViewMode$, |
| diffViewMode => { |
| this.diffViewMode = diffViewMode; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().draftsCount$, |
| draftCount => { |
| this.draftCount = draftCount; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().threads$, |
| threads => { |
| this.commentThreads = threads; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().change$, |
| change => { |
| // The change view is tied to a specific change number, so don't update |
| // change to undefined. |
| if (change) this.change = change; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.userModel.account$, |
| account => { |
| this.account = account; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.userModel.loggedIn$, |
| loggedIn => { |
| this.loggedIn = loggedIn; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getConfigModel().serverConfig$, |
| config => { |
| this.serverConfig = config; |
| this.replyDisabled = false; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getConfigModel().repoConfig$, |
| config => { |
| this.projectConfig = config; |
| } |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.firstConnectedCallback(); |
| this.connected$.next(true); |
| |
| // Make sure to reverse everything below this line in disconnectedCallback(). |
| // Or consider using either firstConnectedCallback() or constructor(). |
| document.addEventListener('visibilitychange', this.handleVisibilityChange); |
| document.addEventListener('scroll', this.handleScroll); |
| } |
| |
| override firstUpdated() { |
| // _onTabSizingChanged is called when iron-items-changed event is fired |
| // from iron-selectable but that is called before the element is present |
| // in view which whereas the method requires paper tabs already be visible |
| // as it relies on dom rect calculation. |
| // _onTabSizingChanged ensures the primary tab(Files/Comments/Checks) is |
| // underlined. |
| assertIsDefined(this.tabs, 'tabs'); |
| whenVisible(this.tabs, () => this.tabs!._onTabSizingChanged()); |
| } |
| |
| /** |
| * For initialization that should only happen once, not again when |
| * re-connecting to the DOM later. |
| */ |
| private firstConnectedCallback() { |
| if (!this.isFirstConnection) return; |
| this.isFirstConnection = false; |
| |
| getPluginLoader() |
| .awaitPluginsLoaded() |
| .then(() => { |
| this.pluginTabsHeaderEndpoints = |
| getPluginEndpoints().getDynamicEndpoints('change-view-tab-header'); |
| this.pluginTabsContentEndpoints = |
| getPluginEndpoints().getDynamicEndpoints('change-view-tab-content'); |
| if ( |
| this.pluginTabsContentEndpoints.length !== |
| this.pluginTabsHeaderEndpoints.length |
| ) { |
| this.reporting.error( |
| 'Plugin change-view-tab', |
| new Error('Mismatch of headers and content.') |
| ); |
| } |
| }) |
| .then(() => this.initActiveTab()); |
| |
| this.throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ => |
| this.handleToggleChangeStar() |
| ); |
| } |
| |
| override disconnectedCallback() { |
| document.removeEventListener( |
| 'visibilitychange', |
| this.handleVisibilityChange |
| ); |
| document.removeEventListener('scroll', this.handleScroll); |
| this.replyRefitTask?.cancel(); |
| this.scrollTask?.cancel(); |
| |
| if (this.updateCheckTimerHandle) { |
| this.cancelUpdateCheckTimer(); |
| } |
| this.connected$.next(false); |
| super.disconnectedCallback(); |
| } |
| |
| protected override willUpdate(changedProperties: PropertyValues): void { |
| if ( |
| changedProperties.has('change') || |
| changedProperties.has('mergeable') || |
| changedProperties.has('currentRevisionActions') |
| ) { |
| this.changeStatuses = this.computeChangeStatusChips(); |
| } |
| } |
| |
| static override get styles() { |
| return [ |
| a11yStyles, |
| paperStyles, |
| sharedStyles, |
| css` |
| .container:not(.loading) { |
| background-color: var(--background-color-tertiary); |
| } |
| .container.loading { |
| color: var(--deemphasized-text-color); |
| padding: var(--spacing-l); |
| } |
| .header { |
| align-items: center; |
| background-color: var(--background-color-primary); |
| border-bottom: 1px solid var(--border-color); |
| display: flex; |
| padding: var(--spacing-s) var(--spacing-l); |
| z-index: 99; /* Less than gr-overlay's backdrop */ |
| } |
| .header.editMode { |
| background-color: var(--edit-mode-background-color); |
| } |
| .header .download { |
| margin-right: var(--spacing-l); |
| } |
| gr-change-status { |
| margin-left: var(--spacing-s); |
| } |
| gr-change-status:first-child { |
| margin-left: 0; |
| } |
| .headerTitle { |
| align-items: center; |
| display: flex; |
| flex: 1; |
| } |
| .headerSubject { |
| font-family: var(--header-font-family); |
| font-size: var(--font-size-h3); |
| font-weight: var(--font-weight-h3); |
| line-height: var(--line-height-h3); |
| margin-left: var(--spacing-l); |
| } |
| .changeNumberColon { |
| color: transparent; |
| } |
| .changeCopyClipboard { |
| margin-left: var(--spacing-s); |
| } |
| .showCopyLinkDialogButton { |
| --gr-button-padding: 0 0 0 var(--spacing-s); |
| --background-color: transparent; |
| margin-left: var(--spacing-s); |
| } |
| #replyBtn { |
| margin-bottom: var(--spacing-m); |
| } |
| gr-change-star { |
| margin-left: var(--spacing-s); |
| } |
| .showCopyLinkDialogButton gr-change-star { |
| margin-left: 0; |
| } |
| a.changeNumber { |
| margin-left: var(--spacing-xs); |
| } |
| gr-reply-dialog { |
| width: 60em; |
| } |
| .changeStatus { |
| text-transform: capitalize; |
| } |
| /* Strong specificity here is needed due to |
| https://github.com/Polymer/polymer/issues/2531 */ |
| .container .changeInfo { |
| display: flex; |
| background-color: var(--background-color-secondary); |
| padding-right: var(--spacing-m); |
| } |
| .changeId { |
| color: var(--deemphasized-text-color); |
| font-family: var(--font-family); |
| margin-top: var(--spacing-l); |
| } |
| section { |
| background-color: var(--view-background-color); |
| box-shadow: var(--elevation-level-1); |
| } |
| .changeMetadata { |
| /* Limit meta section to half of the screen at max */ |
| max-width: 50%; |
| } |
| .commitMessage { |
| font-family: var(--monospace-font-family); |
| font-size: var(--font-size-mono); |
| line-height: var(--line-height-mono); |
| margin-right: var(--spacing-l); |
| margin-bottom: var(--spacing-l); |
| /* Account for border and padding and rounding errors. */ |
| max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px); |
| } |
| .commitMessage gr-formatted-text { |
| word-break: break-word; |
| } |
| #commitMessageEditor { |
| /* Account for border and padding and rounding errors. */ |
| min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px); |
| --collapsed-max-height: 300px; |
| } |
| .changeStatuses, |
| .commitActions { |
| align-items: center; |
| display: flex; |
| } |
| .changeStatuses { |
| flex-wrap: wrap; |
| } |
| .mainChangeInfo { |
| display: flex; |
| flex: 1; |
| flex-direction: column; |
| min-width: 0; |
| } |
| #commitAndRelated { |
| align-content: flex-start; |
| display: flex; |
| flex: 1; |
| overflow-x: hidden; |
| } |
| .relatedChanges { |
| flex: 0 1 auto; |
| overflow: hidden; |
| padding: var(--spacing-l) 0; |
| } |
| .mobile { |
| display: none; |
| } |
| hr { |
| border: 0; |
| border-top: 1px solid var(--border-color); |
| height: 0; |
| margin-bottom: var(--spacing-l); |
| } |
| .emptySpace { |
| flex-grow: 1; |
| } |
| .commitContainer { |
| display: flex; |
| flex-direction: column; |
| flex-shrink: 0; |
| margin: var(--spacing-l) 0; |
| padding: 0 var(--spacing-l); |
| } |
| .showOnEdit { |
| display: none; |
| } |
| .scrollable { |
| overflow: auto; |
| } |
| .text { |
| white-space: pre; |
| } |
| gr-commit-info { |
| display: inline-block; |
| } |
| paper-tabs { |
| background-color: var(--background-color-tertiary); |
| margin-top: var(--spacing-m); |
| height: calc(var(--line-height-h3) + var(--spacing-m)); |
| --paper-tabs-selection-bar-color: var(--link-color); |
| } |
| paper-tab { |
| box-sizing: border-box; |
| max-width: 12em; |
| --paper-tab-ink: var(--link-color); |
| --paper-font-common-base_-_font-family: var(--header-font-family); |
| --paper-font-common-base_-_-webkit-font-smoothing: initial; |
| --paper-tab-content_-_margin-bottom: var(--spacing-s); |
| /* paper-tabs uses 700 here, which can look awkward */ |
| --paper-tab-content-focused_-_font-weight: var(--font-weight-h3); |
| --paper-tab-content-focused_-_background: var( |
| --gray-background-focus |
| ); |
| --paper-tab-content-unselected_-_opacity: 1; |
| --paper-tab-content-unselected_-_color: var( |
| --deemphasized-text-color |
| ); |
| } |
| gr-thread-list, |
| gr-messages-list { |
| display: block; |
| } |
| gr-thread-list { |
| min-height: 250px; |
| } |
| #includedInOverlay { |
| width: 65em; |
| } |
| #uploadHelpOverlay { |
| width: 50em; |
| } |
| #metadata { |
| --metadata-horizontal-padding: var(--spacing-l); |
| padding-top: var(--spacing-l); |
| width: 100%; |
| } |
| gr-change-summary { |
| margin-left: var(--spacing-m); |
| } |
| @media screen and (max-width: 75em) { |
| .relatedChanges { |
| padding: 0; |
| } |
| #relatedChanges { |
| padding-top: var(--spacing-l); |
| } |
| #commitAndRelated { |
| flex-direction: column; |
| flex-wrap: nowrap; |
| } |
| #commitMessageEditor { |
| min-width: 0; |
| } |
| .commitMessage { |
| margin-right: 0; |
| } |
| .mainChangeInfo { |
| padding-right: 0; |
| } |
| } |
| @media screen and (max-width: 50em) { |
| .mobile { |
| display: block; |
| } |
| .header { |
| align-items: flex-start; |
| flex-direction: column; |
| flex: 1; |
| padding: var(--spacing-s) var(--spacing-l); |
| } |
| .headerTitle { |
| flex-wrap: wrap; |
| font-family: var(--header-font-family); |
| font-size: var(--font-size-h3); |
| font-weight: var(--font-weight-h3); |
| line-height: var(--line-height-h3); |
| } |
| .desktop { |
| display: none; |
| } |
| .reply { |
| display: block; |
| margin-right: 0; |
| /* px because don't have the same font size */ |
| margin-bottom: 6px; |
| } |
| .changeInfo-column:not(:last-of-type) { |
| margin-right: 0; |
| padding-right: 0; |
| } |
| .changeInfo, |
| #commitAndRelated { |
| flex-direction: column; |
| flex-wrap: nowrap; |
| } |
| .commitContainer { |
| margin: 0; |
| padding: var(--spacing-l); |
| } |
| .changeMetadata { |
| margin-top: var(--spacing-xs); |
| max-width: none; |
| } |
| #metadata, |
| .mainChangeInfo { |
| padding: 0; |
| } |
| .commitActions { |
| display: block; |
| margin-top: var(--spacing-l); |
| width: 100%; |
| } |
| .commitMessage { |
| flex: initial; |
| margin: 0; |
| } |
| /* Change actions are the only thing thant need to remain visible due |
| to the fact that they may have the currently visible overlay open. */ |
| #mainContent.overlayOpen .hideOnMobileOverlay { |
| display: none; |
| } |
| gr-reply-dialog { |
| height: 100vh; |
| min-width: initial; |
| width: 100vw; |
| } |
| #replyOverlay { |
| z-index: var(--reply-overlay-z-index); |
| } |
| } |
| .patch-set-dropdown { |
| margin: var(--spacing-m) 0 0 var(--spacing-m); |
| } |
| .show-robot-comments { |
| margin: var(--spacing-m); |
| } |
| .tabContent gr-thread-list::part(threads) { |
| padding: var(--spacing-l); |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| return html`${this.renderLoading()}${this.renderMainContent()}`; |
| } |
| |
| private renderLoading() { |
| if (!this.loading) return nothing; |
| return html` |
| <div class="container loading" ?hidden=${!this.loading}>Loading...</div> |
| `; |
| } |
| |
| private renderMainContent() { |
| return html` |
| <div |
| id="mainContent" |
| class="container" |
| ?hidden=${this.loading} |
| aria-hidden=${this.changeViewAriaHidden ? 'true' : 'false'} |
| > |
| ${this.renderChangeInfoSection()} |
| <h2 class="assistive-tech-only">Files and Comments tabs</h2> |
| ${this.renderTabHeaders()} ${this.renderTabContent()} |
| ${this.renderChangeLog()} |
| </div> |
| <gr-apply-fix-dialog |
| id="applyFixDialog" |
| .change=${this.change} |
| .changeNum=${this.changeNum} |
| ></gr-apply-fix-dialog> |
| <gr-overlay id="downloadOverlay" with-backdrop=""> |
| <gr-download-dialog |
| id="downloadDialog" |
| .change=${this.change} |
| .config=${this.serverConfig?.download} |
| @close=${this.handleDownloadDialogClose} |
| ></gr-download-dialog> |
| </gr-overlay> |
| <gr-overlay id="includedInOverlay" with-backdrop=""> |
| <gr-included-in-dialog |
| id="includedInDialog" |
| .changeNum=${this.changeNum} |
| @close=${this.handleIncludedInDialogClose} |
| ></gr-included-in-dialog> |
| </gr-overlay> |
| <gr-overlay |
| id="replyOverlay" |
| class="scrollable" |
| no-cancel-on-outside-click="" |
| no-cancel-on-esc-key="" |
| scroll-action="lock" |
| with-backdrop="" |
| @iron-overlay-canceled=${this.onReplyOverlayCanceled} |
| @opened-changed=${this.onReplyOverlayOpenedChanged} |
| > |
| ${when( |
| this.replyOverlayOpened && this.loggedIn, |
| () => html` |
| <gr-reply-dialog |
| id="replyDialog" |
| .permittedLabels=${this.change?.permitted_labels} |
| .projectConfig=${this.projectConfig} |
| .canBeStarted=${this.canStartReview()} |
| @send=${this.handleReplySent} |
| @cancel=${this.handleReplyCancel} |
| @autogrow=${this.handleReplyAutogrow} |
| @send-disabled-changed=${this.resetReplyOverlayFocusStops} |
| > |
| </gr-reply-dialog> |
| ` |
| )} |
| </gr-overlay> |
| `; |
| } |
| |
| private renderChangeInfoSection() { |
| return html`<section class="changeInfoSection"> |
| <div class=${this.computeHeaderClass()}> |
| <h1 class="assistive-tech-only"> |
| Change ${this.change?._number}: ${this.change?.subject} |
| </h1> |
| ${this.renderHeaderTitle()} ${this.renderCommitActions()} |
| </div> |
| <h2 class="assistive-tech-only">Change metadata</h2> |
| ${this.renderChangeInfo()} |
| </section>`; |
| } |
| |
| private renderHeaderTitle() { |
| const resolveWeblinks = this.commitInfo?.resolve_conflicts_web_links ?? []; |
| return html` <div class="headerTitle"> |
| <div class="changeStatuses"> |
| ${this.changeStatuses.map( |
| status => html` <gr-change-status |
| .change=${this.change} |
| .revertedChange=${this.revertedChange} |
| .status=${status} |
| .resolveWeblinks=${resolveWeblinks} |
| ></gr-change-status>` |
| )} |
| </div> |
| ${this.renderCopyLinksDropdown()} |
| <gr-button |
| flatten |
| down-arrow |
| class="showCopyLinkDialogButton" |
| @click=${() => this.copyLinksDropdown?.toggleDropdown()} |
| ><gr-change-star |
| id="changeStar" |
| .change=${this.change} |
| @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => |
| this.handleToggleStar(e)} |
| ?hidden=${!this.loggedIn} |
| ></gr-change-star> |
| <a |
| class="changeNumber" |
| aria-label=${`Change ${this.change?._number}`} |
| href=${ifDefined(this.computeChangeUrl(true))} |
| @click=${(e: MouseEvent) => e.stopPropagation()} |
| >${this.change?._number}</a |
| > |
| </gr-button> |
| <span class="headerSubject">${this.change?.subject}</span> |
| <gr-copy-clipboard |
| class="changeCopyClipboard" |
| hideInput="" |
| text=${this.computeCopyTextForTitle()} |
| > |
| </gr-copy-clipboard> |
| </div>`; |
| } |
| |
| private renderCopyLinksDropdown() { |
| const url = this.computeChangeUrl(); |
| if (!url) return; |
| const changeURL = prependOrigin(getBaseUrl() + url); |
| const links: CopyLink[] = [ |
| { |
| label: 'Change Number', |
| shortcut: 'n', |
| value: `${this.change?._number}`, |
| }, |
| { |
| label: 'Change URL', |
| shortcut: 'u', |
| value: changeURL, |
| }, |
| { |
| label: 'Title and URL', |
| shortcut: 't', |
| value: `${this.change?._number}: ${this.change?.subject} | ${changeURL}`, |
| }, |
| { |
| label: 'URL and title', |
| shortcut: 'r', |
| value: `${changeURL}: ${this.change?.subject}`, |
| }, |
| { |
| label: 'Markdown', |
| shortcut: 'm', |
| value: `[${this.change?.subject}](${changeURL})`, |
| }, |
| { |
| label: 'Change-Id', |
| shortcut: 'd', |
| value: `${this.change?.id.split('~').pop()}`, |
| }, |
| ]; |
| if ( |
| this.change?.status === ChangeStatus.MERGED && |
| this.change?.current_revision |
| ) { |
| links.push({ |
| label: 'SHA', |
| shortcut: 's', |
| value: this.change.current_revision, |
| }); |
| } |
| return html`<gr-copy-links .copyLinks=${links}> </gr-copy-links>`; |
| } |
| |
| private renderCommitActions() { |
| return html` <div class="commitActions"> |
| <!-- always show gr-change-actions regardless if logged in or not --> |
| <gr-change-actions |
| id="actions" |
| .change=${this.change} |
| .disableEdit=${false} |
| .hasParent=${this.hasParent} |
| .account=${this.account} |
| .changeNum=${this.changeNum} |
| .changeStatus=${this.change?.status} |
| .commitNum=${this.commitInfo?.commit} |
| .commitMessage=${this.latestCommitMessage} |
| .editMode=${this.getEditMode()} |
| .privateByDefault=${this.projectConfig?.private_by_default} |
| .loggedIn=${this.loggedIn} |
| @edit-tap=${() => this.handleEditTap()} |
| @stop-edit-tap=${() => this.handleStopEditTap()} |
| @download-tap=${() => this.handleOpenDownloadDialog()} |
| @included-tap=${() => this.handleOpenIncludedInDialog()} |
| @revision-actions-changed=${this.handleRevisionActionsChanged} |
| ></gr-change-actions> |
| </div>`; |
| } |
| |
| private renderChangeInfo() { |
| const hideEditCommitMessage = this.computeHideEditCommitMessage( |
| this.loggedIn, |
| this.editingCommitMessage, |
| this.change, |
| this.getEditMode() |
| ); |
| return html` <div class="changeInfo"> |
| <div class="changeInfo-column changeMetadata hideOnMobileOverlay"> |
| <gr-change-metadata |
| id="metadata" |
| .change=${this.change} |
| .revertedChange=${this.revertedChange} |
| .account=${this.account} |
| .revision=${this.selectedRevision} |
| .commitInfo=${this.commitInfo} |
| .serverConfig=${this.serverConfig} |
| .parentIsCurrent=${this.isParentCurrent()} |
| .repoConfig=${this.projectConfig} |
| @show-reply-dialog=${this.handleShowReplyDialog} |
| > |
| </gr-change-metadata> |
| </div> |
| <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo"> |
| <div id="commitAndRelated" class="hideOnMobileOverlay"> |
| <div class="commitContainer"> |
| <h3 class="assistive-tech-only">Commit Message</h3> |
| <div> |
| <gr-button |
| id="replyBtn" |
| class="reply" |
| title=${this.createTitle( |
| Shortcut.OPEN_REPLY_DIALOG, |
| ShortcutSection.ACTIONS |
| )} |
| ?hidden=${!this.loggedIn} |
| primary="" |
| .disabled=${this.replyDisabled} |
| @click=${this.handleReplyTap} |
| >${this.computeReplyButtonLabel()}</gr-button |
| > |
| </div> |
| <div id="commitMessage" class="commitMessage"> |
| <gr-editable-content |
| id="commitMessageEditor" |
| .editing=${this.editingCommitMessage} |
| .content=${this.latestCommitMessage} |
| @editing-changed=${this.handleEditingChanged} |
| @content-changed=${this.handleContentChanged} |
| .storageKey=${`c${this.change?._number}_rev${this.change?.current_revision}`} |
| .hideEditCommitMessage=${hideEditCommitMessage} |
| .commitCollapsible=${this.computeCommitCollapsible()} |
| remove-zero-width-space="" |
| > |
| <gr-formatted-text |
| .content=${this.latestCommitMessage ?? ''} |
| .markdown=${false} |
| ></gr-formatted-text> |
| </gr-editable-content> |
| <div class="changeId" ?hidden=${!this.changeIdCommitMessageError}> |
| <hr /> |
| Change-Id: |
| <span |
| class=${this.computeChangeIdClass( |
| this.changeIdCommitMessageError |
| )} |
| title=${this.computeTitleAttributeWarning( |
| this.changeIdCommitMessageError |
| )} |
| >${this.change?.change_id}</span |
| > |
| </div> |
| </div> |
| <h3 class="assistive-tech-only">Comments and Checks Summary</h3> |
| <gr-change-summary></gr-change-summary> |
| <gr-endpoint-decorator name="commit-container"> |
| <gr-endpoint-param name="change" .value=${this.change}> |
| </gr-endpoint-param> |
| <gr-endpoint-param |
| name="revision" |
| .value=${this.selectedRevision} |
| > |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| </div> |
| <div class="relatedChanges"> |
| <gr-related-changes-list |
| id="relatedChanges" |
| .change=${this.change} |
| .mergeable=${this.mergeable} |
| ></gr-related-changes-list> |
| </div> |
| <div class="emptySpace"></div> |
| </div> |
| </div> |
| </div>`; |
| } |
| |
| private renderTabHeaders() { |
| return html` |
| <paper-tabs |
| id="tabs" |
| @selected-changed=${this.onPaperTabSelectionChanged} |
| > |
| <paper-tab @click=${this.onPaperTabClick} data-name=${Tab.FILES} |
| ><span>Files</span></paper-tab |
| > |
| <paper-tab |
| @click=${this.onPaperTabClick} |
| data-name=${Tab.COMMENT_THREADS} |
| class="commentThreads" |
| > |
| <gr-tooltip-content |
| has-tooltip |
| title=${ifDefined(this.computeTotalCommentCounts())} |
| > |
| <span>Comments</span></gr-tooltip-content |
| > |
| </paper-tab> |
| ${when( |
| this.showChecksTab, |
| () => html` |
| <paper-tab data-name=${Tab.CHECKS} @click=${this.onPaperTabClick} |
| ><span>Checks</span></paper-tab |
| > |
| ` |
| )} |
| ${this.pluginTabsHeaderEndpoints.map( |
| tabHeader => html` |
| <paper-tab data-name=${tabHeader}> |
| <gr-endpoint-decorator name=${tabHeader}> |
| <gr-endpoint-param name="change" .value=${this.change}> |
| </gr-endpoint-param> |
| <gr-endpoint-param |
| name="revision" |
| .value=${this.selectedRevision} |
| > |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| </paper-tab> |
| ` |
| )} |
| ${when( |
| this.showFindingsTab, |
| () => html` |
| <paper-tab data-name=${Tab.FINDINGS} @click=${this.onPaperTabClick}> |
| <span>Findings</span> |
| </paper-tab> |
| ` |
| )} |
| </paper-tabs> |
| `; |
| } |
| |
| private renderTabContent() { |
| return html` |
| <section class="tabContent"> |
| ${this.renderFilesTab()} ${this.renderCommentsTab()} |
| ${this.renderChecksTab()} ${this.renderFindingsTab()} |
| ${this.renderPluginTab()} |
| </section> |
| `; |
| } |
| |
| private renderFilesTab() { |
| return html` |
| <div ?hidden=${this.activeTab !== Tab.FILES}> |
| <gr-file-list-header |
| id="fileListHeader" |
| .account=${this.account} |
| .change=${this.change} |
| .changeNum=${this.changeNum} |
| .commitInfo=${this.commitInfo} |
| .changeUrl=${this.computeChangeUrl()} |
| .editMode=${this.getEditMode()} |
| .loggedIn=${this.loggedIn} |
| .shownFileCount=${this.shownFileCount} |
| .filesExpanded=${this.fileList?.filesExpanded} |
| @open-diff-prefs=${this.handleOpenDiffPrefs} |
| @open-download-dialog=${this.handleOpenDownloadDialog} |
| @expand-diffs=${this.expandAllDiffs} |
| @collapse-diffs=${this.collapseAllDiffs} |
| > |
| </gr-file-list-header> |
| <gr-file-list |
| id="fileList" |
| class="hideOnMobileOverlay" |
| .change=${this.change} |
| .changeNum=${this.changeNum} |
| .editMode=${this.getEditMode()} |
| @files-shown-changed=${(e: CustomEvent<{length: number}>) => { |
| this.shownFileCount = e.detail.length; |
| }} |
| @files-expanded-changed=${( |
| _e: ValueChangedEvent<FilesExpandedState> |
| ) => { |
| this.requestUpdate(); |
| }} |
| @file-action-tap=${this.handleFileActionTap} |
| > |
| </gr-file-list> |
| </div> |
| `; |
| } |
| |
| private renderCommentsTab() { |
| if (this.activeTab !== Tab.COMMENT_THREADS) return nothing; |
| return html` |
| <h3 class="assistive-tech-only">Comments</h3> |
| <gr-thread-list |
| .threads=${this.commentThreads} |
| .commentTabState=${this.tabState} |
| only-show-robot-comments-with-human-reply |
| .unresolvedOnly=${this.unresolvedOnly} |
| .scrollCommentId=${this.scrollCommentId} |
| show-comment-context |
| ></gr-thread-list> |
| `; |
| } |
| |
| private renderChecksTab() { |
| if (this.activeTab !== Tab.CHECKS) return nothing; |
| return html` |
| <h3 class="assistive-tech-only">Checks</h3> |
| <gr-checks-tab id="checksTab" .tabState=${this.tabState}></gr-checks-tab> |
| `; |
| } |
| |
| private renderFindingsTab() { |
| if (this.activeTab !== Tab.FINDINGS) return nothing; |
| if (!this.showFindingsTab) return nothing; |
| const robotCommentThreads = this.computeRobotCommentThreads(); |
| const robotCommentsPatchSetDropdownItems = |
| this.computeRobotCommentsPatchSetDropdownItems(); |
| return html` |
| <gr-dropdown-list |
| class="patch-set-dropdown" |
| .items=${robotCommentsPatchSetDropdownItems} |
| .value=${this.currentRobotCommentsPatchSet} |
| @value-change=${this.handleRobotCommentPatchSetChanged} |
| > |
| </gr-dropdown-list> |
| <gr-thread-list .threads=${robotCommentThreads} hide-dropdown> |
| </gr-thread-list> |
| ${when( |
| this.showRobotCommentsButton, |
| () => html` |
| <gr-button |
| class="show-robot-comments" |
| @click=${this.toggleShowRobotComments} |
| > |
| ${this.showAllRobotComments ? 'Show Less' : 'Show more'} |
| </gr-button> |
| ` |
| )} |
| `; |
| } |
| |
| private renderPluginTab() { |
| const i = this.pluginTabsHeaderEndpoints.findIndex( |
| t => this.activeTab === t |
| ); |
| if (i === -1) return nothing; |
| const pluginTabContentEndpoint = this.pluginTabsContentEndpoints[i]; |
| return html` |
| <gr-endpoint-decorator .name=${pluginTabContentEndpoint}> |
| <gr-endpoint-param name="change" .value=${this.change}> |
| </gr-endpoint-param> |
| <gr-endpoint-param name="revision" .value=${this.selectedRevision}></gr-endpoint-param> |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| `; |
| } |
| |
| private renderChangeLog() { |
| return html` |
| <gr-endpoint-decorator name="change-view-integration"> |
| <gr-endpoint-param name="change" .value=${this.change}> |
| </gr-endpoint-param> |
| <gr-endpoint-param name="revision" .value=${this.selectedRevision}> |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| |
| <paper-tabs> |
| <paper-tab data-name="_changeLog" class="changeLog"> |
| Change Log |
| </paper-tab> |
| </paper-tabs> |
| <section class="changeLog"> |
| <h2 class="assistive-tech-only">Change Log</h2> |
| <gr-messages-list |
| class="hideOnMobileOverlay" |
| .labels=${this.change?.labels} |
| .messages=${this.change?.messages} |
| .reviewerUpdates=${this.change?.reviewer_updates} |
| @message-anchor-tap=${this.handleMessageAnchorTap} |
| @reply=${this.handleMessageReply} |
| ></gr-messages-list> |
| </section> |
| `; |
| } |
| |
| private readonly handleScroll = () => { |
| if (!this.isViewCurrent) return; |
| this.scrollTask = debounce( |
| this.scrollTask, |
| () => (this.scrollPosition = document.documentElement.scrollTop), |
| 150 |
| ); |
| }; |
| |
| private onOpenFixPreview(e: OpenFixPreviewEvent) { |
| assertIsDefined(this.applyFixDialog); |
| this.applyFixDialog.open(e); |
| } |
| |
| private onCloseFixPreview(e: CloseFixPreviewEvent) { |
| if (e.detail.fixApplied) fireReload(this); |
| } |
| |
| // Private but used in tests. |
| handleToggleDiffMode() { |
| if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) { |
| this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED}); |
| } else { |
| this.userModel.updatePreferences({ |
| diff_view: DiffViewMode.SIDE_BY_SIDE, |
| }); |
| } |
| } |
| |
| onPaperTabSelectionChanged(e: ValueChangedEvent) { |
| if (!this.tabs) return; |
| const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')]; |
| if (!tabs) return; |
| |
| const tabIndex = Number(e.detail.value); |
| assert( |
| Number.isInteger(tabIndex) && 0 <= tabIndex && tabIndex < tabs.length, |
| `${tabIndex} must be integer` |
| ); |
| const tab = tabs[tabIndex].dataset['name']; |
| |
| this.getViewModel().updateState({tab}); |
| } |
| |
| setActiveTab(e: SwitchTabEvent) { |
| if (!this.tabs) return; |
| const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')]; |
| if (!tabs) return; |
| |
| const tab = e.detail.tab; |
| const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab); |
| assert(tabIndex !== -1, `tab ${tab} not found`); |
| |
| if (this.tabs.selected !== tabIndex) { |
| this.tabs.selected = tabIndex; |
| } |
| |
| this.getViewModel().updateState({tab}); |
| |
| if (e.detail.tabState) this.tabState = e.detail.tabState; |
| if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'}); |
| } |
| |
| /** |
| * Currently there is a bug in this code where this.unresolvedOnly is only |
| * assigned the correct value when onPaperTabClick is triggered which is |
| * only triggered when user explicitly clicks on the tab however the comments |
| * tab can also be opened via the url in which case the correct value to |
| * unresolvedOnly is never assigned. |
| */ |
| private 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 === Tab.COMMENT_THREADS) { |
| // Show unresolved threads by default |
| // Show resolved threads only if no unresolved threads exist |
| const hasUnresolvedThreads = |
| (this.commentThreads ?? []).filter(thread => isUnresolved(thread)) |
| .length > 0; |
| if (!hasUnresolvedThreads) this.unresolvedOnly = false; |
| } |
| |
| this.reporting.reportInteraction(Interaction.SHOW_TAB, { |
| tabName, |
| src: 'paper-tab-click', |
| }); |
| } |
| |
| private handleEditingChanged(e: ValueChangedEvent<boolean>) { |
| this.editingCommitMessage = e.detail.value; |
| } |
| |
| private handleContentChanged(e: ValueChangedEvent) { |
| this.latestCommitMessage = e.detail.value; |
| } |
| |
| // Private but used in tests. |
| handleCommitMessageSave(e: EditableContentSaveEvent) { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.changeNum, 'changeNum'); |
| // to prevent 2 requests at the same time |
| if (!this.commitMessageEditor || this.commitMessageEditor.disabled) return; |
| // 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 => { |
| assertIsDefined(this.commitMessageEditor); |
| this.commitMessageEditor.disabled = false; |
| if (!resp.ok) { |
| return; |
| } |
| |
| this.latestCommitMessage = this.prepareCommitMsgForLinkify(message); |
| this.editingCommitMessage = false; |
| fireReload(this, true); |
| }) |
| .catch(() => { |
| assertIsDefined(this.commitMessageEditor); |
| this.commitMessageEditor.disabled = false; |
| }); |
| } |
| |
| private handleCommitMessageCancel() { |
| this.editingCommitMessage = false; |
| } |
| |
| private computeChangeStatusChips() { |
| if (!this.change) { |
| return []; |
| } |
| |
| // Show no chips until mergeability is loaded. |
| if (this.mergeable === null) { |
| return []; |
| } |
| |
| const options = { |
| includeDerived: true, |
| mergeable: !!this.mergeable, |
| submitEnabled: !!this.isSubmitEnabled(), |
| }; |
| return changeStatuses(this.change as ChangeInfo, options); |
| } |
| |
| // Private but used in tests. |
| computeHideEditCommitMessage( |
| loggedIn: boolean, |
| editing: boolean, |
| change?: ParsedChangeInfo, |
| editMode?: boolean |
| ) { |
| if ( |
| !loggedIn || |
| editing || |
| (change && change.status === ChangeStatus.MERGED) || |
| editMode |
| ) { |
| return true; |
| } |
| |
| return false; |
| } |
| |
| // Private but used in tests. |
| 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}); |
| } |
| |
| // Private but used in tests. |
| computeText( |
| patch: RevisionInfo | EditRevisionInfo, |
| 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')})`; |
| } |
| |
| private computeRobotCommentsPatchSetDropdownItems() { |
| if (!this.change || !this.commentThreads || !this.change.revisions) |
| return []; |
| |
| return Object.values(this.change.revisions) |
| .filter(patch => patch._number !== EDIT) |
| .map(patch => { |
| return { |
| text: this.computeText(patch, this.commentThreads!), |
| value: patch._number, |
| }; |
| }) |
| .sort((a, b) => (b.value as number) - (a.value as number)); |
| } |
| |
| private handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) { |
| const patchSet = Number(e.detail.value) as PatchSetNum; |
| if (patchSet === this.currentRobotCommentsPatchSet) return; |
| this.currentRobotCommentsPatchSet = patchSet; |
| } |
| |
| private toggleShowRobotComments() { |
| this.showAllRobotComments = !this.showAllRobotComments; |
| } |
| |
| // Private but used in tests. |
| computeRobotCommentThreads() { |
| if (!this.commentThreads || !this.currentRobotCommentsPatchSet) return []; |
| const threads = this.commentThreads.filter(thread => { |
| const comments = thread.comments || []; |
| return ( |
| comments.length && |
| isRobot(comments[0]) && |
| comments[0].patch_set === this.currentRobotCommentsPatchSet |
| ); |
| }); |
| this.showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT; |
| return threads.slice( |
| 0, |
| this.showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT |
| ); |
| } |
| |
| private computeTotalCommentCounts() { |
| const unresolvedCount = this.change?.unresolved_comment_count ?? 0; |
| const draftCount = this.draftCount; |
| 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 |
| ); |
| } |
| |
| private handleReplyTap(e: MouseEvent) { |
| e.preventDefault(); |
| this.openReplyDialog(FocusTarget.ANY); |
| } |
| |
| private onReplyOverlayCanceled() { |
| fireDialogChange(this, {canceled: true}); |
| this.changeViewAriaHidden = false; |
| } |
| |
| private onReplyOverlayOpenedChanged(e: ValueChangedEvent<boolean>) { |
| this.replyOverlayOpened = e.detail.value; |
| } |
| |
| private handleOpenDiffPrefs() { |
| assertIsDefined(this.fileList); |
| this.fileList.openDiffPrefs(); |
| } |
| |
| private handleOpenIncludedInDialog() { |
| assertIsDefined(this.includedInDialog); |
| assertIsDefined(this.includedInOverlay); |
| this.includedInDialog.loadData().then(() => { |
| assertIsDefined(this.includedInOverlay); |
| flush(); |
| this.includedInOverlay.refit(); |
| }); |
| this.includedInOverlay.open(); |
| } |
| |
| private handleIncludedInDialogClose() { |
| assertIsDefined(this.includedInOverlay); |
| this.includedInOverlay.close(); |
| } |
| |
| // Private but used in tests |
| handleOpenDownloadDialog() { |
| assertIsDefined(this.downloadOverlay); |
| this.downloadOverlay.open().then(() => { |
| assertIsDefined(this.downloadOverlay); |
| assertIsDefined(this.downloadDialog); |
| this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops()); |
| this.downloadDialog.focus(); |
| }); |
| } |
| |
| private handleDownloadDialogClose() { |
| assertIsDefined(this.downloadOverlay); |
| this.downloadOverlay.close(); |
| } |
| |
| // Private but used in tests. |
| 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.openReplyDialog(FocusTarget.BODY, quoteStr); |
| } |
| |
| // Private but used in tests. |
| handleHideBackgroundContent() { |
| assertIsDefined(this.mainContent); |
| this.mainContent.classList.add('overlayOpen'); |
| } |
| |
| // Private but used in tests. |
| handleShowBackgroundContent() { |
| assertIsDefined(this.mainContent); |
| this.mainContent.classList.remove('overlayOpen'); |
| } |
| |
| // Private but used in tests. |
| handleReplySent() { |
| this.addEventListener( |
| 'change-details-loaded', |
| () => { |
| this.reporting.timeEnd(Timing.SEND_REPLY); |
| }, |
| {once: true} |
| ); |
| assertIsDefined(this.replyOverlay); |
| this.replyOverlay.cancel(); |
| fireReload(this); |
| } |
| |
| private handleReplyCancel() { |
| assertIsDefined(this.replyOverlay); |
| this.replyOverlay.cancel(); |
| } |
| |
| private handleReplyAutogrow() { |
| // If the textarea resizes, we need to re-fit the overlay. |
| this.replyRefitTask = debounce( |
| this.replyRefitTask, |
| () => { |
| assertIsDefined(this.replyOverlay); |
| this.replyOverlay.refit(); |
| }, |
| REPLY_REFIT_DEBOUNCE_INTERVAL_MS |
| ); |
| } |
| |
| // Private but used in tests. |
| handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) { |
| let target = FocusTarget.REVIEWERS; |
| if (e.detail.value && e.detail.value.ccsOnly) { |
| target = FocusTarget.CCS; |
| } |
| this.openReplyDialog(target); |
| } |
| |
| private expandAllDiffs() { |
| assertIsDefined(this.fileList); |
| this.fileList.expandAllDiffs(); |
| } |
| |
| private collapseAllDiffs() { |
| assertIsDefined(this.fileList); |
| 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 |
| * the navigation service's set/replaceUrl() methods 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 viewState.changeNum yet. Not obsolete in that case. |
| if (this.changeNum === undefined) return false; |
| // this.viewState 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.viewState?.changeNum; |
| } |
| |
| // Private but used in tests. |
| hasPatchRangeChanged(viewState: ChangeViewState) { |
| if (!this.patchRange) return false; |
| if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true; |
| return this.hasPatchNumChanged(viewState); |
| } |
| |
| // Private but used in tests. |
| hasPatchNumChanged(viewState: ChangeViewState) { |
| if (!this.patchRange) return false; |
| if (viewState.patchNum !== undefined) { |
| return this.patchRange.patchNum !== viewState.patchNum; |
| } else { |
| // value.patchNum === undefined specifies the latest patchset |
| return ( |
| this.patchRange.patchNum !== computeLatestPatchNum(this.allPatchSets) |
| ); |
| } |
| } |
| |
| // Private but used in tests. |
| viewStateChanged() { |
| if (this.viewState === undefined) { |
| 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 (this.viewState.changeNum && this.viewState.project) { |
| this.restApiService.setInProjectLookup( |
| this.viewState.changeNum, |
| this.viewState.project |
| ); |
| } |
| |
| if (this.viewState.basePatchNum === undefined) |
| this.viewState.basePatchNum = PARENT; |
| |
| const patchChanged = this.hasPatchRangeChanged(this.viewState); |
| let patchNumChanged = this.hasPatchNumChanged(this.viewState); |
| |
| this.patchRange = { |
| patchNum: this.viewState.patchNum, |
| basePatchNum: this.viewState.basePatchNum, |
| }; |
| this.scrollCommentId = this.viewState.commentId; |
| |
| const patchKnown = |
| !this.patchRange.patchNum || |
| (this.allPatchSets ?? []).some( |
| ps => ps.num === this.patchRange!.patchNum |
| ); |
| // _allPatchsets does not know value.patchNum so force a reload. |
| const forceReload = this.viewState.forceReload || !patchKnown; |
| |
| // If changeNum is defined that means the change has already been |
| // rendered once before so a full reload is not required. |
| if (this.changeNum !== undefined && !forceReload) { |
| if (!this.patchRange.patchNum) { |
| this.patchRange = { |
| ...this.patchRange, |
| patchNum: computeLatestPatchNum(this.allPatchSets), |
| }; |
| patchNumChanged = true; |
| } |
| if (patchChanged) { |
| // We need to collapse all diffs when viewState changes so that a non |
| // existing diff is not requested. See Issue 125270 for more details. |
| this.fileList?.resetFileState(); |
| this.fileList?.collapseAllDiffs(); |
| this.reloadPatchNumDependentResources(patchNumChanged); |
| } |
| |
| // If there is no change in patchset or changeNum, such as when user goes |
| // to the diff view and then comes back to change page then there is no |
| // need to reload anything and we render the change view component as is. |
| document.documentElement.scrollTop = this.scrollPosition ?? 0; |
| this.reporting.reportInteraction('change-view-re-rendered'); |
| this.updateTitle(this.change); |
| // We still need to check if post load tasks need to be done such as when |
| // user wants to open the reply dialog when in the diff page, the change |
| // page should open the reply dialog |
| this.performPostLoadTasks(); |
| return; |
| } |
| |
| // We need to collapse all diffs when viewState changes so that a non |
| // existing diff is not requested. See Issue 125270 for more details. |
| this.updateComplete.then(() => { |
| assertIsDefined(this.fileList); |
| this.fileList?.collapseAllDiffs(); |
| this.fileList?.resetFileState(); |
| }); |
| |
| // If the change was loaded before, then we are firing a 'reload' event |
| // instead of calling `loadData()` directly for two reasons: |
| // 1. We want to avoid code such as `this.initialLoadComplete = false` that |
| // is only relevant for the initial load of a change. |
| // 2. We have to somehow trigger the change-model reloading. Otherwise |
| // this.change is not updated. |
| if (this.changeNum) { |
| if (!this._patchRange?.patchNum) { |
| this._patchRange = { |
| basePatchNum: PARENT, |
| patchNum: computeLatestPatchNum(this.allPatchSets), |
| }; |
| } |
| fireReload(this); |
| return; |
| } |
| |
| this.initialLoadComplete = false; |
| this.changeNum = this.viewState.changeNum; |
| this.loadData(true).then(() => { |
| this.performPostLoadTasks(); |
| }); |
| |
| getPluginLoader() |
| .awaitPluginsLoaded() |
| .then(() => { |
| this.initActiveTab(); |
| }); |
| } |
| |
| private initActiveTab() { |
| let tab = Tab.FILES; |
| if (this.viewState?.tab) { |
| tab = this.viewState?.tab as Tab; |
| } else if (this.viewState?.commentId) { |
| tab = Tab.COMMENT_THREADS; |
| } |
| this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}})); |
| } |
| |
| // Private but used in tests. |
| sendShowChangeEvent() { |
| assertIsDefined(this.patchRange, 'patchRange'); |
| this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, { |
| change: this.change, |
| patchNum: this.patchRange.patchNum, |
| info: {mergeable: this.mergeable}, |
| }); |
| } |
| |
| private performPostLoadTasks() { |
| this.maybeShowReplyDialog(); |
| this.maybeShowRevertDialog(); |
| |
| this.sendShowChangeEvent(); |
| |
| this.updateComplete.then(() => { |
| this.maybeScrollToMessage(window.location.hash); |
| this.initialLoadComplete = true; |
| }); |
| } |
| |
| // Private but used in tests. |
| handleMessageAnchorTap(e: CustomEvent<{id: string}>) { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| const hash = PREFIX + e.detail.id; |
| const url = createChangeUrl({ |
| change: this.change, |
| patchNum: this.patchRange.patchNum, |
| basePatchNum: this.patchRange.basePatchNum, |
| edit: this.getEditMode(), |
| messageHash: hash, |
| }); |
| history.replaceState(null, '', url); |
| } |
| |
| // Private but used in tests. |
| maybeScrollToMessage(hash: string) { |
| if (hash.startsWith(PREFIX) && this.messagesList) { |
| this.messagesList.scrollToMessage(hash.substr(PREFIX.length)); |
| } |
| } |
| |
| // Private but used in tests. |
| 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; |
| } |
| |
| // Private but used in tests. |
| maybeShowRevertDialog() { |
| getPluginLoader() |
| .awaitPluginsLoaded() |
| .then(() => { |
| if ( |
| !this.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')) { |
| assertIsDefined(this.actions); |
| this.actions.showRevertDialog(); |
| } |
| }); |
| } |
| |
| private maybeShowReplyDialog() { |
| if (!this.loggedIn) return; |
| if (this.viewState?.openReplyDialog) { |
| this.openReplyDialog(FocusTarget.ANY); |
| } |
| } |
| |
| private updateTitle(change?: ChangeInfo | ParsedChangeInfo) { |
| if (!change) return; |
| const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')'; |
| fireTitleChange(this, title); |
| } |
| |
| // Private but used in tests. |
| changeChanged(oldChange: ParsedChangeInfo | undefined) { |
| this.allPatchSets = computeAllPatchSets(this.change); |
| if (!this.change) return; |
| this.labelsChanged(oldChange?.labels, this.change.labels); |
| if ( |
| this.change.current_revision && |
| this.change.revisions && |
| this.change.revisions[this.change.current_revision] |
| ) { |
| this.currentRobotCommentsPatchSet = |
| this.change.revisions[this.change.current_revision]._number; |
| } |
| if (!this.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(); |
| |
| this.patchRange = { |
| ...this.patchRange, |
| basePatchNum: parent, |
| patchNum: |
| this.patchRange.patchNum || computeLatestPatchNum(this.allPatchSets), |
| }; |
| this.updateTitle(this.change); |
| } |
| |
| /** |
| * Gets base patch number, if it is a parent try and decide from |
| * preference whether to default to `auto merge`, `Parent 1` or `PARENT`. |
| * Private but used in tests. |
| */ |
| getBasePatchNum() { |
| if ( |
| this.patchRange && |
| this.patchRange.basePatchNum && |
| this.patchRange.basePatchNum !== PARENT |
| ) { |
| return this.patchRange.basePatchNum; |
| } |
| |
| const revisionInfo = this.getRevisionInfo(); |
| if (!revisionInfo) return PARENT; |
| |
| // TODO: It is a bit unclear why `1` is used here instead of |
| // `patchRange.patchNum`. Maybe that is a bug? Maybe if one patchset |
| // is a merge commit, then all patchsets are merge commits?? |
| const isMerge = revisionInfo.isMergeCommit(1 as PatchSetNumber); |
| const preferFirst = |
| this.prefs && |
| this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT; |
| |
| // TODO: I think checking `!patchRange.patchNum` here is a bug and means |
| // that the feature is actually broken at the moment. Looking at the |
| // `changeChanged` method, `patchRange.patchNum` is set before |
| // `getBasePatchNum` is called, so it is unlikely that this method will |
| // ever return -1. |
| if (isMerge && preferFirst && !this.patchRange?.patchNum) { |
| this.reporting.reportExecution(Execution.PREFER_MERGE_FIRST_PARENT); |
| return -1 as BasePatchSetNum; |
| } |
| return PARENT; |
| } |
| |
| private computeChangeUrl(forceReload?: boolean) { |
| if (!this.change) return undefined; |
| return createChangeUrl({ |
| change: this.change, |
| forceReload: !!forceReload, |
| }); |
| } |
| |
| // private but used in test |
| computeChangeIdClass(displayChangeId?: string | null) { |
| if (displayChangeId) { |
| return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : ''; |
| } |
| return ''; |
| } |
| |
| computeTitleAttributeWarning(displayChangeId?: string | null) { |
| if (!displayChangeId) { |
| return undefined; |
| } |
| 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 | null, |
| change?: ParsedChangeInfo |
| ) { |
| 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; |
| } |
| |
| // Private but used in tests. |
| computeReplyButtonLabel() { |
| if (this.diffDrafts === undefined) { |
| return 'Reply'; |
| } |
| |
| const draftCount = Object.keys(this.diffDrafts).reduce( |
| (count, file) => count + this.diffDrafts![file].length, |
| 0 |
| ); |
| |
| let label = this.canStartReview() ? 'Start Review' : 'Reply'; |
| if (draftCount > 0) { |
| label += ` (${draftCount})`; |
| } |
| return label; |
| } |
| |
| private handleOpenReplyDialog() { |
| if (!this.loggedIn) { |
| fireEvent(this, 'show-auth-required'); |
| return; |
| } |
| this.openReplyDialog(FocusTarget.ANY); |
| } |
| |
| private handleOpenSubmitDialog() { |
| if (!this.isSubmitEnabled()) return; |
| assertIsDefined(this.actions); |
| this.actions.showSubmitDialog(); |
| } |
| |
| // Private but used in tests. |
| handleToggleAttentionSet() { |
| if (!this.change || !this.account?._account_id) return; |
| if (!this.loggedIn || !isInvolved(this.change, this.account)) return; |
| const newChange = {...this.change}; |
| if (!newChange.attention_set) newChange.attention_set = {}; |
| if (hasAttention(this.account, this.change)) { |
| const reason = getRemovedByReason(this.account, this.serverConfig); |
| if (newChange.attention_set) |
| delete newChange.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 ...'); |
| newChange.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 = newChange; |
| } |
| |
| // Private but used in tests. |
| handleDiffAgainstBase() { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| if (this.patchRange.basePatchNum === PARENT) { |
| fireAlert(this, 'Base is already selected.'); |
| return; |
| } |
| this.getNavigation().setUrl( |
| createChangeUrl({change: this.change, patchNum: this.patchRange.patchNum}) |
| ); |
| } |
| |
| // Private but used in tests. |
| handleDiffBaseAgainstLeft() { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| |
| if (this.patchRange.basePatchNum === PARENT) { |
| fireAlert(this, 'Left is already base.'); |
| return; |
| } |
| this.getNavigation().setUrl( |
| createChangeUrl({ |
| change: this.change, |
| patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum, |
| }) |
| ); |
| } |
| |
| // Private but used in tests. |
| handleDiffAgainstLatest() { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| const latestPatchNum = computeLatestPatchNum(this.allPatchSets); |
| if (this.patchRange.patchNum === latestPatchNum) { |
| fireAlert(this, 'Latest is already selected.'); |
| return; |
| } |
| this.getNavigation().setUrl( |
| createChangeUrl({ |
| change: this.change, |
| patchNum: latestPatchNum, |
| basePatchNum: this.patchRange.basePatchNum, |
| }) |
| ); |
| } |
| |
| // Private but used in tests. |
| handleDiffRightAgainstLatest() { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| const latestPatchNum = computeLatestPatchNum(this.allPatchSets); |
| if (this.patchRange.patchNum === latestPatchNum) { |
| fireAlert(this, 'Right is already latest.'); |
| return; |
| } |
| this.getNavigation().setUrl( |
| createChangeUrl({ |
| change: this.change, |
| patchNum: latestPatchNum, |
| basePatchNum: this.patchRange.patchNum as BasePatchSetNum, |
| }) |
| ); |
| } |
| |
| // Private but used in tests. |
| handleDiffBaseAgainstLatest() { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| const latestPatchNum = computeLatestPatchNum(this.allPatchSets); |
| if ( |
| this.patchRange.patchNum === latestPatchNum && |
| this.patchRange.basePatchNum === PARENT |
| ) { |
| fireAlert(this, 'Already diffing base against latest.'); |
| return; |
| } |
| this.getNavigation().setUrl( |
| createChangeUrl({change: this.change, patchNum: latestPatchNum}) |
| ); |
| } |
| |
| private handleToggleChangeStar() { |
| assertIsDefined(this.changeStar); |
| this.changeStar.toggleStar(); |
| } |
| |
| private handleExpandAllMessages() { |
| if (this.messagesList) { |
| this.messagesList.handleExpandCollapse(true); |
| } |
| } |
| |
| private handleCollapseAllMessages() { |
| if (this.messagesList) { |
| this.messagesList.handleExpandCollapse(false); |
| } |
| } |
| |
| private handleOpenDiffPrefsShortcut() { |
| if (!this.loggedIn) return; |
| assertIsDefined(this.fileList); |
| this.fileList.openDiffPrefs(); |
| } |
| |
| private determinePageBack() { |
| // Default backPage to root if user came to change view page |
| // via an email link, etc. |
| this.getNavigation().setUrl(this.backPage || rootUrl()); |
| } |
| |
| private handleLabelRemoved( |
| oldLabels: LabelNameToInfoMap, |
| newLabels: LabelNameToInfoMap |
| ) { |
| for (const key in oldLabels) { |
| if (!Object.prototype.hasOwnProperty.call(oldLabels, key)) continue; |
| const oldLabelInfo: QuickLabelInfo & DetailedLabelInfo = oldLabels[key]; |
| const newLabelInfo: (QuickLabelInfo & DetailedLabelInfo) | undefined = |
| newLabels[key]; |
| if (!newLabelInfo) continue; |
| if (!oldLabelInfo.all || !newLabelInfo.all) continue; |
| const oldAccounts = oldLabelInfo.all.map(x => x._account_id); |
| const newAccounts = newLabelInfo.all.map(x => x._account_id); |
| for (const account of oldAccounts) { |
| if ( |
| !newAccounts.includes(account) && |
| newLabelInfo.approved?._account_id === account |
| ) { |
| fireReload(this); |
| return; |
| } |
| } |
| } |
| } |
| |
| private labelsChanged( |
| oldLabels: LabelNameToInfoMap | undefined, |
| newLabels: LabelNameToInfoMap | undefined |
| ) { |
| if (!oldLabels || !newLabels) { |
| return; |
| } |
| this.handleLabelRemoved(oldLabels, newLabels); |
| this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, { |
| change: this.change, |
| }); |
| } |
| |
| openReplyDialog(focusTarget?: FocusTarget, quote?: string) { |
| if (!this.change) return; |
| assertIsDefined(this.replyOverlay); |
| const overlay = this.replyOverlay; |
| overlay.open().finally(() => { |
| // the following code should be executed no matter open succeed or not |
| const dialog = this.replyDialog; |
| assertIsDefined(dialog, 'reply dialog'); |
| this.resetReplyOverlayFocusStops(); |
| dialog.open(focusTarget, quote); |
| const observer = new ResizeObserver(() => overlay.center()); |
| observer.observe(dialog); |
| }); |
| fireDialogChange(this, {opened: true}); |
| this.changeViewAriaHidden = true; |
| } |
| |
| // Private but used in tests. |
| 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. |
| * Private but used in tests. |
| */ |
| processEdit(change: ParsedChangeInfo) { |
| const revisions = Object.values(change.revisions || {}); |
| const editRev = findEdit(revisions); |
| const editParentRev = findEditParentRevision(revisions); |
| if ( |
| !editRev && |
| this.patchRange?.patchNum === EDIT && |
| changeIsOpen(change) |
| ) { |
| fireAlert(this, 'Change edit not found. Please create a change edit.'); |
| fireReload(this, true); |
| return; |
| } |
| |
| if ( |
| !editRev && |
| (changeIsMerged(change) || changeIsAbandoned(change)) && |
| this.getEditMode() |
| ) { |
| fireAlert( |
| this, |
| 'Change edits cannot be created if change is merged or abandoned. Redirecting to non edit mode.' |
| ); |
| fireReload(this, true); |
| return; |
| } |
| |
| if (!editRev) return; |
| assertIsDefined(this.patchRange, 'patchRange'); |
| assertIsDefined(editRev.commit.commit, 'editRev.commit.commit'); |
| assertIsDefined(editParentRev, 'editParentRev'); |
| |
| const latestPsNum = computeLatestPatchNum(computeAllPatchSets(change)); |
| // If the change was loaded without a specific patchset, then this normally |
| // means that the *latest* patchset should be loaded. But if there is an |
| // active edit, then automatically switch to that edit as the current |
| // patchset. |
| // TODO: This goes together with `change.current_revision` being set, which |
| // is under change-model control. `patchRange.patchNum` should eventually |
| // also be model managed, so we can reconcile these two code snippets into |
| // one location. |
| if (!this.routerPatchNum && latestPsNum === editParentRev._number) { |
| this.patchRange = {...this.patchRange, patchNum: EDIT}; |
| // The file list is not reactive (yet) with regards to patch range |
| // changes, so we have to actively trigger it. |
| this.reloadPatchNumDependentResources(); |
| } |
| } |
| |
| 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.changeStatuses = this.changeStatuses.concat([ |
| ChangeStates.REVERT_SUBMITTED, |
| ]); |
| } else { |
| if (changes[0]) this.revertedChange = changes[0]; |
| this.changeStatuses = this.changeStatuses.concat([ |
| ChangeStates.REVERT_CREATED, |
| ]); |
| } |
| }); |
| } |
| |
| private async untilModelLoaded() { |
| // NOTE: Wait until this page is connected before determining whether the |
| // model is loaded. This can happen when viewState changes when setting up |
| // this view. It's unclear whether this issue is related to Polymer |
| // specifically. |
| if (!this.isConnected) { |
| await until(this.connected$, connected => connected); |
| } |
| await until( |
| this.getChangeModel().changeLoadingStatus$, |
| status => status === LoadingStatus.LOADED |
| ); |
| } |
| |
| /** |
| * Process edits |
| * Check if a revert of this change has been submitted |
| * Calculate selected revision |
| */ |
| // private but used in tests |
| async performPostChangeLoadTasks() { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| |
| const prefCompletes = this.restApiService.getPreferences(); |
| await this.untilModelLoaded(); |
| |
| this.prefs = await prefCompletes; |
| |
| if (!this.change) return false; |
| |
| this.processEdit(this.change); |
| // 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 (!this.change.topic) { |
| this.change.topic = null as unknown as undefined; |
| } |
| if (!this.change.reviewer_updates) { |
| this.change.reviewer_updates = null as unknown as undefined; |
| } |
| const latestRevisionSha = this.getLatestRevisionSHA(this.change); |
| if (!latestRevisionSha) |
| throw new Error('Could not find latest Revision Sha'); |
| const currentRevision = this.change.revisions[latestRevisionSha]; |
| if (currentRevision.commit && currentRevision.commit.message) { |
| this.latestCommitMessage = this.prepareCommitMsgForLinkify( |
| currentRevision.commit.message |
| ); |
| } else { |
| this.latestCommitMessage = null; |
| } |
| |
| this.computeRevertSubmitted(this.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; |
| } |
| |
| private isParentCurrent() { |
| const revisionActions = this.currentRevisionActions; |
| if (revisionActions && revisionActions.rebase) { |
| return !revisionActions.rebase.enabled; |
| } else { |
| return true; |
| } |
| } |
| |
| // Private but used in tests. |
| getLatestCommitMessage() { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| 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 |
| ); |
| }); |
| } |
| |
| // Private but used in tests. |
| 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; |
| } |
| |
| // visible for testing |
| loadAndSetCommitInfo() { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| assertIsDefined(this.patchRange?.patchNum, 'patchRange.patchNum'); |
| return this.restApiService |
| .getChangeCommitInfo(this.changeNum, this.patchRange.patchNum) |
| .then(commitInfo => { |
| this.commitInfo = commitInfo; |
| }); |
| } |
| |
| /** |
| * Reload the change. |
| * |
| * @param isLocationChange Reloads the related changes |
| * when true and ends reporting events that started on location change. |
| * @param clearPatchset Reloads the change 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) { |
| if (this.isChangeObsolete()) return Promise.resolve(); |
| if (clearPatchset && this.change) { |
| this.getNavigation().setUrl( |
| createChangeUrl({change: this.change, forceReload: true}) |
| ); |
| 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.untilModelLoaded(); |
| allDataPromises.push(detailCompletes); |
| |
| // Resolves when the loading flag is set to false, meaning that some |
| // change content may start appearing. |
| const loadingFlagSet = detailCompletes.then(() => { |
| this.loading = false; |
| this.performPostChangeLoadTasks(); |
| }); |
| |
| 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. |
| coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]); |
| } else { |
| 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); |
| |
| coreDataPromise = loadingFlagSet; |
| } |
| const mergeabilityLoaded = coreDataPromise.then(() => |
| this.getMergeability() |
| ); |
| allDataPromises.push(mergeabilityLoaded); |
| |
| coreDataPromise.then(() => { |
| fireEvent(this, 'change-details-loaded'); |
| this.reporting.timeEnd(Timing.CHANGE_RELOAD); |
| if (isLocationChange) { |
| this.reporting.changeDisplayed(roleDetails(this.change, this.account)); |
| } |
| }); |
| |
| if (isLocationChange) { |
| this.editingCommitMessage = false; |
| } |
| const relatedChangesLoaded = coreDataPromise.then(() => { |
| let relatedChangesPromise: |
| | Promise<RelatedChangesInfo | undefined> |
| | undefined; |
| const patchNum = 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; |
| }); |
| } |
| return this.getRelatedChangesList()?.reload(relatedChangesPromise); |
| }); |
| allDataPromises.push(relatedChangesLoaded); |
| allDataPromises.push(this.filesLoaded()); |
| |
| 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; |
| } |
| |
| private async filesLoaded() { |
| if (!this.isConnected) await until(this.connected$, connected => connected); |
| await until(this.getFilesModel().files$, f => f.length > 0); |
| } |
| |
| /** |
| * 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. |
| * |
| * Private but used in tests. |
| */ |
| 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(patchNumChanged?: boolean) { |
| assertIsDefined(this.changeNum, 'changeNum'); |
| if (!this.patchRange?.patchNum) throw new Error('missing patchNum'); |
| const promises = [this.loadAndSetCommitInfo()]; |
| if (patchNumChanged) { |
| promises.push( |
| this.getCommentsModel().reloadPortedComments( |
| this.changeNum, |
| this.patchRange?.patchNum |
| ) |
| ); |
| promises.push( |
| this.getCommentsModel().reloadPortedDrafts( |
| this.changeNum, |
| this.patchRange?.patchNum |
| ) |
| ); |
| } |
| return Promise.all(promises); |
| } |
| |
| // Private but used in tests |
| 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; |
| } |
| }); |
| } |
| |
| /** |
| * Returns the text to be copied when |
| * click the copy icon next to change subject |
| * Private but used in tests. |
| */ |
| computeCopyTextForTitle(): string { |
| return ( |
| `${this.change?._number}: ${this.change?.subject} | ` + |
| `${location.protocol}//${location.host}` + |
| `${this.computeChangeUrl()}` |
| ); |
| } |
| |
| private computeCommitCollapsible() { |
| if (!this.latestCommitMessage) { |
| return false; |
| } |
| return ( |
| this.latestCommitMessage.split('\n').length >= |
| MIN_LINES_FOR_COMMIT_COLLAPSE |
| ); |
| } |
| |
| private 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.change) { |
| this.startUpdateCheckTimer(); |
| return; |
| } |
| const change = this.change; |
| this.getChangeModel() |
| .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>(EventType.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); |
| } |
| |
| private 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(); |
| } |
| }; |
| |
| // Private but used in tests. |
| computeHeaderClass() { |
| const classes = ['header']; |
| if (this.getEditMode()) { |
| classes.push('editMode'); |
| } |
| return classes.join(' '); |
| } |
| |
| private handleFileActionTap(e: CustomEvent<{path: string; action: string}>) { |
| e.preventDefault(); |
| assertIsDefined(this.fileListHeader); |
| const controls = |
| this.fileListHeader.shadowRoot!.querySelector<GrEditControls>( |
| '#editControls' |
| ); |
| if (!controls) throw new Error('Missing edit controls'); |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| |
| const path = e.detail.path; |
| switch (e.detail.action) { |
| case GrEditConstants.Actions.DELETE.id: |
| controls.openDeleteDialog(path); |
| break; |
| case GrEditConstants.Actions.OPEN.id: |
| assertIsDefined(this.patchRange.patchNum, 'patchset number'); |
| this.getNavigation().setUrl( |
| createEditUrl({ |
| changeNum: this.change._number, |
| project: this.change.project, |
| path, |
| patchNum: this.patchRange.patchNum, |
| }) |
| ); |
| break; |
| case GrEditConstants.Actions.RENAME.id: |
| controls.openRenameDialog(path); |
| break; |
| case GrEditConstants.Actions.RESTORE.id: |
| controls.openRestoreDialog(path); |
| break; |
| } |
| } |
| |
| private patchNumChanged() { |
| if (!this.selectedRevision || !this.patchRange?.patchNum) { |
| return; |
| } |
| assertIsDefined(this.change, 'change'); |
| |
| if (this.patchRange.patchNum === this.selectedRevision._number) { |
| return; |
| } |
| if (!this.change.revisions) return; |
| this.selectedRevision = Object.values(this.change.revisions).find( |
| revision => revision._number === this.patchRange!.patchNum |
| ); |
| } |
| |
| /** |
| * If an edit exists already, load it. Otherwise, toggle edit mode via the |
| * navigation API. |
| */ |
| private 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 === EDIT |
| ); |
| |
| if (editInfo) { |
| const url = createChangeUrl({change: this.change, patchNum: EDIT}); |
| this.getNavigation().setUrl(url); |
| return; |
| } |
| |
| this.getNavigation().setUrl( |
| createChangeUrl({ |
| change: this.change, |
| patchNum: this.routerPatchNum, |
| edit: true, |
| forceReload: true, |
| }) |
| ); |
| } |
| |
| private handleStopEditTap() { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.patchRange, 'patchRange'); |
| this.getNavigation().setUrl( |
| createChangeUrl({ |
| change: this.change, |
| patchNum: this.patchRange.patchNum, |
| forceReload: true, |
| }) |
| ); |
| } |
| |
| private resetReplyOverlayFocusStops() { |
| const dialog = this.replyDialog; |
| const focusStops = dialog?.getFocusStops(); |
| if (!focusStops) return; |
| assertIsDefined(this.replyOverlay); |
| this.replyOverlay.setFocusStops(focusStops); |
| } |
| |
| // Private but used in tests. |
| async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) { |
| 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'); |
| } |
| } |
| const msg = e.detail.starred |
| ? 'Starring change...' |
| : 'Unstarring change...'; |
| fireAlert(this, msg); |
| await this.restApiService.saveChangeStarred( |
| e.detail.change._number, |
| e.detail.starred |
| ); |
| fireEvent(this, 'hide-alert'); |
| } |
| |
| private getRevisionInfo(): RevisionInfoClass | undefined { |
| if (this.change === undefined) return undefined; |
| return new RevisionInfoClass(this.change); |
| } |
| |
| getRelatedChangesList() { |
| return this.shadowRoot!.querySelector<GrRelatedChangesList>( |
| '#relatedChanges' |
| ); |
| } |
| |
| createTitle(shortcutName: Shortcut, section: ShortcutSection) { |
| return this.getShortcutsService().createTitle(shortcutName, section); |
| } |
| |
| private handleRevisionActionsChanged( |
| e: CustomEvent<{value: ActionNameToActionInfoMap}> |
| ) { |
| this.currentRevisionActions = e.detail.value; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementEventMap { |
| 'toggle-star': CustomEvent<ChangeStarToggleStarDetail>; |
| } |
| interface HTMLElementTagNameMap { |
| 'gr-change-view': GrChangeView; |
| } |
| } |