/** * @license * Copyright (C) 2019 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 '../styles/shared-styles'; import '../styles/themes/app-theme'; import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme'; import './admin/gr-admin-view/gr-admin-view'; import './documentation/gr-documentation-search/gr-documentation-search'; import './change-list/gr-change-list-view/gr-change-list-view'; import './change-list/gr-dashboard-view/gr-dashboard-view'; import './change/gr-change-view/gr-change-view'; import './core/gr-error-manager/gr-error-manager'; import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog'; import './core/gr-main-header/gr-main-header'; import './core/gr-router/gr-router'; import './core/gr-smart-search/gr-smart-search'; import './diff/gr-diff-view/gr-diff-view'; import './edit/gr-editor-view/gr-editor-view'; import './plugins/gr-endpoint-decorator/gr-endpoint-decorator'; import './plugins/gr-endpoint-param/gr-endpoint-param'; import './plugins/gr-endpoint-slot/gr-endpoint-slot'; import './plugins/gr-external-style/gr-external-style'; import './plugins/gr-plugin-host/gr-plugin-host'; import './settings/gr-cla-view/gr-cla-view'; import './settings/gr-registration-dialog/gr-registration-dialog'; import './settings/gr-settings-view/gr-settings-view'; import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; import {PolymerElement} from '@polymer/polymer/polymer-element'; import {htmlTemplate} from './gr-app-element_html'; import {getBaseUrl} from '../utils/url-util'; import { KeyboardShortcutMixin, Shortcut, SPECIAL_SHORTCUT, } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin'; import {GerritNav} from './core/gr-navigation/gr-navigation'; import {appContext} from '../services/app-context'; import {flush} from '@polymer/polymer/lib/utils/flush'; import {customElement, observe, property} from '@polymer/decorators'; import {GrRouter} from './core/gr-router/gr-router'; import { AccountDetailInfo, ElementPropertyDeepChange, ServerInfo, } from '../types/common'; import {GrErrorManager} from './core/gr-error-manager/gr-error-manager'; import {GrOverlay} from './shared/gr-overlay/gr-overlay'; import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog'; import { AppElementJustRegisteredParams, AppElementParams, isAppElementJustRegisteredParams, } from './gr-app-types'; import {GrMainHeader} from './core/gr-main-header/gr-main-header'; import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view'; import { CustomKeyboardEvent, LocationChangeEvent, PageErrorEventDetail, RpcLogEvent, ShortcutTriggeredEvent, TitleChangeEventDetail, DialogChangeEventDetail, } from '../types/events'; import {ViewState} from '../types/types'; import {EventType} from '../utils/event-util'; import {GerritView} from '../services/router/router-model'; import {windowLocationReload} from '../utils/dom-util'; interface ErrorInfo { text: string; emoji?: string; moreInfo?: string; } export interface GrAppElement { $: { router: GrRouter; errorManager: GrErrorManager; errorView: HTMLDivElement; mainHeader: GrMainHeader; }; } // TODO(TS): implement AppElement interface from gr-app-types.ts @customElement('gr-app-element') export class GrAppElement extends KeyboardShortcutMixin( GestureEventListeners(LegacyElementMixin(PolymerElement)) ) { static get template() { return htmlTemplate; } /** * Fired when the URL location changes. * * @event location-change */ @property({type: Object}) params?: AppElementParams; @property({type: Object}) keyEventTarget = document.body; @property({type: Object, observer: '_accountChanged'}) _account?: AccountDetailInfo; @property({type: Number}) _lastGKeyPressTimestamp: number | null = null; @property({type: Object}) _serverConfig?: ServerInfo; @property({type: String}) _version?: string; @property({type: Boolean}) _showChangeListView?: boolean; @property({type: Boolean}) _showDashboardView?: boolean; @property({type: Boolean}) _showChangeView?: boolean; @property({type: Boolean}) _showDiffView?: boolean; @property({type: Boolean}) _showSettingsView?: boolean; @property({type: Boolean}) _showAdminView?: boolean; @property({type: Boolean}) _showCLAView?: boolean; @property({type: Boolean}) _showEditorView?: boolean; @property({type: Boolean}) _showPluginScreen?: boolean; @property({type: Boolean}) _showDocumentationSearch?: boolean; @property({type: Object}) _viewState?: ViewState; @property({type: Object}) _lastError?: ErrorInfo; @property({type: String}) _lastSearchPage?: string; @property({type: String}) _path?: string; @property({type: String, computed: '_computePluginScreenName(params)'}) _pluginScreenName?: string; @property({type: String}) _settingsUrl?: string; @property({type: String}) _feedbackUrl?: string; @property({type: Boolean}) mobileSearch = false; @property({type: String}) _loginUrl = '/login'; @property({type: Boolean}) loadRegistrationDialog = false; @property({type: Boolean}) loadKeyboardShortcutsDialog = false; // TODO(milutin) - remove once new gr-dialog will do it out of the box // This removes footer, header from a11y tree, when a dialog on view // (e.g. reply dialog) is open @property({type: Boolean}) _footerHeaderAriaHidden = false; // TODO(milutin) - remove once new gr-dialog will do it out of the box // This removes main page from a11y tree, when a dialog on gr-app-element // (e.g. shortcut dialog) is open @property({type: Boolean}) _mainAriaHidden = false; private reporting = appContext.reportingService; private restApiService = appContext.restApiService; keyboardShortcuts() { return { [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts', [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard', [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges', [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges', [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges', [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges', }; } /** @override */ created() { super.created(); this._bindKeyboardShortcuts(); document.addEventListener(EventType.PAGE_ERROR, e => { this._handlePageError(e); }); this.addEventListener(EventType.TITLE_CHANGE, e => { this._handleTitleChange(e); }); this.addEventListener(EventType.DIALOG_CHANGE, e => { this._handleDialogChange(e as CustomEvent); }); this.addEventListener('location-change', e => this._handleLocationChange(e) ); document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e)); this.addEventListener('shortcut-triggered', e => this._handleShortcutTriggered(e) ); // Ideally individual views should handle this event and respond with a soft // reload. This is a catch-all for all views that cannot or have not // implemented that. this.addEventListener('reload', () => windowLocationReload()); } /** @override */ ready() { super.ready(); this._updateLoginUrl(); this.reporting.appStarted(); this.$.router.start(); this.restApiService.getAccount().then(account => { this._account = account; const role = account ? 'user' : 'guest'; this.reporting.reportLifeCycle(`Started as ${role}`); }); this.restApiService.getConfig().then(config => { this._serverConfig = config; if (config && config.gerrit && config.gerrit.report_bug_url) { this._feedbackUrl = config.gerrit.report_bug_url; } }); this.restApiService.getVersion().then(version => { this._version = version; this._logWelcome(); }); if (window.localStorage.getItem('dark-theme')) { applyDarkTheme(); } // Note: this is evaluated here to ensure that it only happens after the // router has been initialized. @see Issue 7837 this._settingsUrl = GerritNav.getUrlForSettings(); this._viewState = { changeView: { changeNum: null, patchRange: null, selectedFileIndex: 0, showReplyDialog: false, showDownloadDialog: false, diffMode: null, numFilesShown: null, scrollTop: 0, }, changeListView: { query: null, offset: 0, selectedChangeIndex: 0, }, dashboardView: {}, }; } _bindKeyboardShortcuts() { this.bindShortcut( Shortcut.SEND_REPLY, SPECIAL_SHORTCUT.DOC_ONLY, 'ctrl+enter', 'meta+enter' ); this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':'); this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?'); this.bindShortcut( Shortcut.GO_TO_USER_DASHBOARD, SPECIAL_SHORTCUT.GO_KEY, 'i' ); this.bindShortcut( Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o' ); this.bindShortcut( Shortcut.GO_TO_MERGED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'm' ); this.bindShortcut( Shortcut.GO_TO_ABANDONED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'a' ); this.bindShortcut( Shortcut.GO_TO_WATCHED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'w' ); this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j'); this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k'); this.bindShortcut(Shortcut.OPEN_CHANGE, 'o'); this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']'); this.bindShortcut(Shortcut.PREV_PAGE, 'p', '['); this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup'); this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown'); this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup'); this.bindShortcut(Shortcut.EDIT_TOPIC, 't'); this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup'); this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup'); this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x'); this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z'); this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup'); this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u'); this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u'); this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup'); this.bindShortcut( Shortcut.DIFF_AGAINST_BASE, SPECIAL_SHORTCUT.V_KEY, 'down', 's' ); // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast // in gr-diff-view. Any updates here should be reflected there this.bindShortcut( Shortcut.DIFF_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'up', 'w' ); // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast // in gr-diff-view. Any updates here should be reflected there this.bindShortcut( Shortcut.DIFF_BASE_AGAINST_LEFT, SPECIAL_SHORTCUT.V_KEY, 'left', 'a' ); this.bindShortcut( Shortcut.DIFF_RIGHT_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'right', 'd' ); this.bindShortcut( Shortcut.DIFF_BASE_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'b' ); this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down'); this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up'); if (this._isCursorManagerSupportMoveToVisibleLine()) { this.bindShortcut(Shortcut.VISIBLE_LINE, '.'); } this.bindShortcut(Shortcut.NEXT_CHUNK, 'n'); this.bindShortcut(Shortcut.PREV_CHUNK, 'p'); this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x'); this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n'); this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p'); this.bindShortcut( Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e' ); this.bindShortcut( Shortcut.COLLAPSE_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'shift+e' ); this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left'); this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right'); this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a'); this.bindShortcut(Shortcut.NEW_COMMENT, 'c'); this.bindShortcut( Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s' ); this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ','); this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup'); this.bindShortcut(Shortcut.NEXT_FILE, ']'); this.bindShortcut(Shortcut.PREV_FILE, '['); this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j'); this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k'); this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down'); this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up'); this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter'); this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup'); this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m'); this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup'); this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup'); this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup'); this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h'); this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f'); this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']'); this.bindShortcut(Shortcut.OPEN_LAST_FILE, '['); this.bindShortcut(Shortcut.SEARCH, '/'); } _isCursorManagerSupportMoveToVisibleLine() { // This method is a copy-paste from the // method _isIntersectionObserverSupported of gr-cursor-manager.js // It is better share this method with gr-cursor-manager, // but doing it require a lot if changes instead of 1-line copied code return 'IntersectionObserver' in window; } _accountChanged(account?: AccountDetailInfo) { if (!account) return; // Preferences are cached when a user is logged in; warm them. this.restApiService.getPreferences(); this.restApiService.getDiffPreferences(); this.restApiService.getEditPreferences(); this.$.errorManager.knownAccountId = (this._account && this._account._account_id) || null; } @observe('params.view') _viewChanged(view?: GerritView) { this.$.errorView.classList.remove('show'); this.set('_showChangeListView', view === GerritView.SEARCH); this.set('_showDashboardView', view === GerritView.DASHBOARD); this.set('_showChangeView', view === GerritView.CHANGE); this.set('_showDiffView', view === GerritView.DIFF); this.set('_showSettingsView', view === GerritView.SETTINGS); // _showAdminView must be in sync with the gr-admin-view AdminViewParams type this.set( '_showAdminView', view === GerritView.ADMIN || view === GerritView.GROUP || view === GerritView.REPO ); this.set('_showCLAView', view === GerritView.AGREEMENTS); this.set('_showEditorView', view === GerritView.EDIT); const isPluginScreen = view === GerritView.PLUGIN_SCREEN; this.set('_showPluginScreen', false); // Navigation within plugin screens does not restamp gr-endpoint-decorator // because _showPluginScreen value does not change. To force restamp, // change _showPluginScreen value between true and false. if (isPluginScreen) { this.async(() => this.set('_showPluginScreen', true), 1); } this.set( '_showDocumentationSearch', view === GerritView.DOCUMENTATION_SEARCH ); if ( this.params && isAppElementJustRegisteredParams(this.params) && this.params.justRegistered ) { this.loadRegistrationDialog = true; flush(); const registrationOverlay = this.shadowRoot!.querySelector( '#registrationOverlay' ) as GrOverlay; const registrationDialog = this.shadowRoot!.querySelector( '#registrationDialog' ) as GrRegistrationDialog; registrationOverlay.open(); registrationDialog.loadData().then(() => { registrationOverlay.refit(); }); } // To fix bug announce read after each new view, we reset announce with // empty space this.fire('iron-announce', {text: ' '}, {bubbles: true}); } _handleShortcutTriggered(event: ShortcutTriggeredEvent) { const {event: e, goKey, vKey} = event.detail; // eg: {key: "k:keydown", ..., from: "gr-diff-view"} let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`; if (goKey) key = 'g+' + key; if (vKey) key = 'v+' + key; if (e.shiftKey) key = 'shift+' + key; if (e.ctrlKey) key = 'ctrl+' + key; if (e.metaKey) key = 'meta+' + key; if (e.altKey) key = 'alt+' + key; const path = event.composedPath(); this.reporting.reportInteraction('shortcut-triggered', { key, from: (path && path[0] && (path[0] as Element).nodeName) ?? 'unknown', }); } _handlePageError(e: CustomEvent) { const props = [ '_showChangeListView', '_showDashboardView', '_showChangeView', '_showDiffView', '_showSettingsView', '_showAdminView', ]; for (const showProp of props) { this.set(showProp, false); } this.$.errorView.classList.add('show'); const response = e.detail.response; const err: ErrorInfo = { text: [response.status, response.statusText].join(' '), }; if (response.status === 404) { err.emoji = '¯\\_(ツ)_/¯'; this._lastError = err; } else { err.emoji = 'o_O'; response.text().then(text => { err.moreInfo = text; this._lastError = err; }); } } _handleLocationChange(e: LocationChangeEvent) { this._updateLoginUrl(); const hash = e.detail.hash.substring(1); let pathname = e.detail.pathname; if (pathname.startsWith('/c/') && Number(hash) > 0) { pathname += '@' + hash; } this.set('_path', pathname); } _updateLoginUrl() { const baseUrl = getBaseUrl(); if (baseUrl) { // Strip the canonical path from the path since needing canonical in // the path is unneeded and breaks the url. this._loginUrl = baseUrl + '/login/' + encodeURIComponent( '/' + window.location.pathname.substring(baseUrl.length) + window.location.search + window.location.hash ); } else { this._loginUrl = '/login/' + encodeURIComponent( window.location.pathname + window.location.search + window.location.hash ); } } @observe('params.*') _paramsChanged( paramsRecord: ElementPropertyDeepChange ) { const params = paramsRecord.base; const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD]; if (params?.view && viewsToCheck.includes(params.view)) { this.set('_lastSearchPage', location.pathname); } } _handleTitleChange(e: CustomEvent) { if (e.detail.title) { document.title = e.detail.title + ' · Gerrit Code Review'; } else { document.title = ''; } } _handleDialogChange(e: CustomEvent) { if (e.detail.canceled) { this._footerHeaderAriaHidden = false; } else if (e.detail.opened) { this._footerHeaderAriaHidden = true; } } handleShowKeyboardShortcuts() { this.loadKeyboardShortcutsDialog = true; flush(); (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open(); } _showKeyboardShortcuts(e: CustomKeyboardEvent) { // same shortcut should close the dialog if pressed again // when dialog is open this.loadKeyboardShortcutsDialog = true; flush(); const keyboardShortcuts = this.shadowRoot!.querySelector( '#keyboardShortcuts' ) as GrOverlay; if (!keyboardShortcuts) return; if (keyboardShortcuts.opened) { keyboardShortcuts.cancel(); return; } if (this.shouldSuppressKeyboardShortcut(e)) { return; } keyboardShortcuts.open(); this._footerHeaderAriaHidden = true; this._mainAriaHidden = true; } _handleKeyboardShortcutDialogClose() { (this.shadowRoot!.querySelector( '#keyboardShortcuts' ) as GrOverlay).cancel(); } onOverlayCanceled() { this._footerHeaderAriaHidden = false; this._mainAriaHidden = false; } _handleAccountDetailUpdate() { this.$.mainHeader.reload(); if (this.params?.view === GerritView.SETTINGS) { (this.shadowRoot!.querySelector( 'gr-settings-view' ) as GrSettingsView).reloadAccountDetail(); } } _handleRegistrationDialogClose() { // The registration dialog is visible only if this.params is // instanceof AppElementJustRegisteredParams (this.params as AppElementJustRegisteredParams).justRegistered = false; (this.shadowRoot!.querySelector( '#registrationOverlay' ) as GrOverlay).close(); } _goToOpenedChanges() { GerritNav.navigateToStatusSearch('open'); } _goToUserDashboard() { GerritNav.navigateToUserDashboard(); } _goToMergedChanges() { GerritNav.navigateToStatusSearch('merged'); } _goToAbandonedChanges() { GerritNav.navigateToStatusSearch('abandoned'); } _goToWatchedChanges() { // The query is hardcoded, and doesn't respect custom menu entries GerritNav.navigateToSearchQuery('is:watched is:open'); } _computePluginScreenName(params: AppElementParams) { if (params.view !== GerritView.PLUGIN_SCREEN) return ''; if (!params.plugin || !params.screen) return ''; return `${params.plugin}-screen-${params.screen}`; } _logWelcome() { console.group('Runtime Info'); console.info('Gerrit UI (PolyGerrit)'); console.info(`Gerrit Server Version: ${this._version}`); if (window.VERSION_INFO) { console.info(`UI Version Info: ${window.VERSION_INFO}`); } if (this._feedbackUrl) { console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`); } console.groupEnd(); } /** * Intercept RPC log events emitted by REST API interfaces. * Note: the REST API interface cannot use gr-reporting directly because * that would create a cyclic dependency. */ _handleRpcLog(e: RpcLogEvent) { this.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed); } _mobileSearchToggle() { this.mobileSearch = !this.mobileSearch; } getThemeEndpoint() { // For now, we only have dark mode and light mode return window.localStorage.getItem('dark-theme') ? 'app-theme-dark' : 'app-theme-light'; } } declare global { interface HTMLElementTagNameMap { 'gr-app-element': GrAppElement; } }