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