/** * @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 '../scripts/util.js'; import '../scripts/bundled-polymer.js'; import '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js'; import '../styles/shared-styles.js'; import '../styles/themes/app-theme.js'; import './admin/gr-admin-view/gr-admin-view.js'; import './documentation/gr-documentation-search/gr-documentation-search.js'; import './change-list/gr-change-list-view/gr-change-list-view.js'; import './change-list/gr-dashboard-view/gr-dashboard-view.js'; import './change/gr-change-view/gr-change-view.js'; import './core/gr-error-manager/gr-error-manager.js'; import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js'; import './core/gr-main-header/gr-main-header.js'; import './core/gr-navigation/gr-navigation.js'; import './core/gr-reporting/gr-reporting.js'; import './core/gr-router/gr-router.js'; import './core/gr-smart-search/gr-smart-search.js'; import './diff/gr-diff-view/gr-diff-view.js'; import './edit/gr-editor-view/gr-editor-view.js'; import './plugins/gr-endpoint-decorator/gr-endpoint-decorator.js'; import './plugins/gr-endpoint-param/gr-endpoint-param.js'; import './plugins/gr-external-style/gr-external-style.js'; import './plugins/gr-plugin-host/gr-plugin-host.js'; import './settings/gr-cla-view/gr-cla-view.js'; import './settings/gr-registration-dialog/gr-registration-dialog.js'; import './settings/gr-settings-view/gr-settings-view.js'; import './shared/gr-fixed-panel/gr-fixed-panel.js'; import './shared/gr-lib-loader/gr-lib-loader.js'; import './shared/gr-rest-api-interface/gr-rest-api-interface.js'; import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js'; import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js'; import {PolymerElement} from '@polymer/polymer/polymer-element.js'; import moment from 'moment/src/moment.js'; self.moment = moment; import {htmlTemplate} from './gr-app-element_html.js'; import {BaseUrlBehavior} from '../behaviors/base-url-behavior/base-url-behavior.js'; /** * @appliesMixin Gerrit.KeyboardShortcutMixin * @extends Polymer.Element */ class GrAppElement extends mixinBehaviors( [ BaseUrlBehavior, Gerrit.KeyboardShortcutBehavior, ], GestureEventListeners( LegacyElementMixin( PolymerElement))) { static get template() { return htmlTemplate; } static get is() { return 'gr-app-element'; } /** * Fired when the URL location changes. * * @event location-change */ static get properties() { return { /** * @type {{ query: string, view: string, screen: string }} */ params: Object, keyEventTarget: { type: Object, value() { return document.body; }, }, _account: { type: Object, observer: '_accountChanged', }, /** * The last time the g key was pressed in milliseconds (or a keydown event * was handled if the key is held down). * * @type {number|null} */ _lastGKeyPressTimestamp: { type: Number, value: null, }, /** * @type {{ plugin: Object }} */ _serverConfig: Object, _version: String, _showChangeListView: Boolean, _showDashboardView: Boolean, _showChangeView: Boolean, _showDiffView: Boolean, _showSettingsView: Boolean, _showAdminView: Boolean, _showCLAView: Boolean, _showEditorView: Boolean, _showPluginScreen: Boolean, _showDocumentationSearch: Boolean, /** @type {?} */ _viewState: Object, /** @type {?} */ _lastError: Object, _lastSearchPage: String, _path: String, _pluginScreenName: { type: String, computed: '_computePluginScreenName(params)', }, _settingsUrl: String, _feedbackUrl: String, // Used to allow searching on mobile mobileSearch: { type: Boolean, value: false, }, /** * Other elements in app must open this URL when * user login is required. */ _loginUrl: { type: String, value: '/login', }, }; } static get observers() { return [ '_viewChanged(params.view)', '_paramsChanged(params.*)', ]; } keyboardShortcuts() { return { [this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts', [this.Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard', [this.Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges', [this.Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges', [this.Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges', [this.Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges', }; } /** @override */ created() { super.created(); this._bindKeyboardShortcuts(); this.addEventListener('page-error', e => this._handlePageError(e)); this.addEventListener('title-change', e => this._handleTitleChange(e)); this.addEventListener('location-change', e => this._handleLocationChange(e)); this.addEventListener('rpc-log', e => this._handleRpcLog(e)); this.addEventListener('shortcut-triggered', e => this._handleShortcutTriggered(e)); } /** @override */ ready() { super.ready(); this._updateLoginUrl(); this.$.reporting.appStarted(); this.$.router.start(); this.$.restAPI.getAccount().then(account => { this._account = account; }); this.$.restAPI.getConfig().then(config => { this._serverConfig = config; if (config && config.gerrit && config.gerrit.report_bug_url) { this._feedbackUrl = config.gerrit.report_bug_url; } }); this.$.restAPI.getVersion().then(version => { this._version = version; this._logWelcome(); }); if (window.localStorage.getItem('dark-theme')) { // No need to add the style module to element again as it's imported // by importHref already this.$.libLoader.getDarkTheme(); } // Note: this is evaluated here to ensure that it only happens after the // router has been initialized. @see Issue 7837 this._settingsUrl = Gerrit.Nav.getUrlForSettings(); this._viewState = { changeView: { changeNum: null, patchRange: null, selectedFileIndex: 0, showReplyDialog: false, diffMode: null, numFilesShown: null, scrollTop: 0, }, changeListView: { query: null, offset: 0, selectedChangeIndex: 0, }, dashboardView: { selectedChangeIndex: 0, }, }; } _bindKeyboardShortcuts() { this.bindShortcut(this.Shortcut.SEND_REPLY, this.DOC_ONLY, 'ctrl+enter', 'meta+enter'); this.bindShortcut(this.Shortcut.EMOJI_DROPDOWN, this.DOC_ONLY, ':'); this.bindShortcut( this.Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?'); this.bindShortcut( this.Shortcut.GO_TO_USER_DASHBOARD, this.GO_KEY, 'i'); this.bindShortcut( this.Shortcut.GO_TO_OPENED_CHANGES, this.GO_KEY, 'o'); this.bindShortcut( this.Shortcut.GO_TO_MERGED_CHANGES, this.GO_KEY, 'm'); this.bindShortcut( this.Shortcut.GO_TO_ABANDONED_CHANGES, this.GO_KEY, 'a'); this.bindShortcut( this.Shortcut.GO_TO_WATCHED_CHANGES, this.GO_KEY, 'w'); this.bindShortcut( this.Shortcut.CURSOR_NEXT_CHANGE, 'j'); this.bindShortcut( this.Shortcut.CURSOR_PREV_CHANGE, 'k'); this.bindShortcut( this.Shortcut.OPEN_CHANGE, 'o'); this.bindShortcut( this.Shortcut.NEXT_PAGE, 'n', ']'); this.bindShortcut( this.Shortcut.PREV_PAGE, 'p', '['); this.bindShortcut( this.Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup'); this.bindShortcut( this.Shortcut.TOGGLE_CHANGE_STAR, 's:keyup'); this.bindShortcut( this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup'); this.bindShortcut( this.Shortcut.EDIT_TOPIC, 't'); this.bindShortcut( this.Shortcut.OPEN_REPLY_DIALOG, 'a'); this.bindShortcut( this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd'); this.bindShortcut( this.Shortcut.EXPAND_ALL_MESSAGES, 'x'); this.bindShortcut( this.Shortcut.COLLAPSE_ALL_MESSAGES, 'z'); this.bindShortcut( this.Shortcut.REFRESH_CHANGE, 'shift+r:keyup'); this.bindShortcut( this.Shortcut.UP_TO_DASHBOARD, 'u'); this.bindShortcut( this.Shortcut.UP_TO_CHANGE, 'u'); this.bindShortcut( this.Shortcut.TOGGLE_DIFF_MODE, 'm:keyup'); this.bindShortcut( this.Shortcut.NEXT_LINE, 'j', 'down'); this.bindShortcut( this.Shortcut.PREV_LINE, 'k', 'up'); if (this._isCursorManagerSupportMoveToVisibleLine()) { this.bindShortcut( this.Shortcut.VISIBLE_LINE, '.'); } this.bindShortcut( this.Shortcut.NEXT_CHUNK, 'n'); this.bindShortcut( this.Shortcut.PREV_CHUNK, 'p'); this.bindShortcut( this.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x'); this.bindShortcut( this.Shortcut.NEXT_COMMENT_THREAD, 'shift+n'); this.bindShortcut( this.Shortcut.PREV_COMMENT_THREAD, 'shift+p'); this.bindShortcut( this.Shortcut.EXPAND_ALL_COMMENT_THREADS, this.DOC_ONLY, 'e'); this.bindShortcut( this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, this.DOC_ONLY, 'shift+e'); this.bindShortcut( this.Shortcut.LEFT_PANE, 'shift+left'); this.bindShortcut( this.Shortcut.RIGHT_PANE, 'shift+right'); this.bindShortcut( this.Shortcut.TOGGLE_LEFT_PANE, 'shift+a'); this.bindShortcut( this.Shortcut.NEW_COMMENT, 'c'); this.bindShortcut( this.Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s'); this.bindShortcut( this.Shortcut.OPEN_DIFF_PREFS, ','); this.bindShortcut( this.Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup'); this.bindShortcut( this.Shortcut.NEXT_FILE, ']'); this.bindShortcut( this.Shortcut.PREV_FILE, '['); this.bindShortcut( this.Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j'); this.bindShortcut( this.Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k'); this.bindShortcut( this.Shortcut.CURSOR_NEXT_FILE, 'j', 'down'); this.bindShortcut( this.Shortcut.CURSOR_PREV_FILE, 'k', 'up'); this.bindShortcut( this.Shortcut.OPEN_FILE, 'o', 'enter'); this.bindShortcut( this.Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup'); this.bindShortcut( this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m'); this.bindShortcut( this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup'); this.bindShortcut( this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup'); this.bindShortcut( this.Shortcut.TOGGLE_BLAME, 'b'); this.bindShortcut( this.Shortcut.OPEN_FIRST_FILE, ']'); this.bindShortcut( this.Shortcut.OPEN_LAST_FILE, '['); this.bindShortcut( this.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) { if (!account) { return; } // Preferences are cached when a user is logged in; warm them. this.$.restAPI.getPreferences(); this.$.restAPI.getDiffPreferences(); this.$.restAPI.getEditPreferences(); this.$.errorManager.knownAccountId = this._account && this._account._account_id || null; } _viewChanged(view) { this.$.errorView.classList.remove('show'); this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH); this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD); this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE); this.set('_showDiffView', view === Gerrit.Nav.View.DIFF); this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS); this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN || view === Gerrit.Nav.View.GROUP || view === Gerrit.Nav.View.REPO); this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS); this.set('_showEditorView', view === Gerrit.Nav.View.EDIT); const isPluginScreen = view === Gerrit.Nav.View.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 === Gerrit.Nav.View.DOCUMENTATION_SEARCH); if (this.params.justRegistered) { this.$.registrationOverlay.open(); this.$.registrationDialog.loadData().then(() => { this.$.registrationOverlay.refit(); }); } this.$.header.unfloat(); } _handleShortcutTriggered(event) { const {event: e, goKey} = event.detail; // eg: {key: "k:keydown", ..., from: "gr-diff-view"} let key = `${e.key}:${e.type}`; if (goKey) key = 'g+' + 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; this.$.reporting.reportInteraction('shortcut-triggered', { key, from: event.path && event.path[0] && event.path[0].nodeName || 'unknown', }); } _handlePageError(e) { 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 = {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) { this._updateLoginUrl(); const hash = e.detail.hash.substring(1); let pathname = e.detail.pathname; if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) { pathname += '@' + hash; } this.set('_path', pathname); } _updateLoginUrl() { const baseUrl = this.getBaseUrl(); if (baseUrl) { // Strip the canonical path from the path since needing canonical in // the path is uneeded 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); } } _paramsChanged(paramsRecord) { const params = paramsRecord.base; const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD]; if (viewsToCheck.includes(params.view)) { this.set('_lastSearchPage', location.pathname); } } _handleTitleChange(e) { if (e.detail.title) { document.title = e.detail.title + ' · Gerrit Code Review'; } else { document.title = ''; } } _showKeyboardShortcuts(e) { // same shortcut should close the dialog if pressed again // when dialog is open if (this.$.keyboardShortcuts.opened) { this.$.keyboardShortcuts.close(); return; } if (this.shouldSuppressKeyboardShortcut(e)) { return; } this.$.keyboardShortcuts.open(); } _handleKeyboardShortcutDialogClose() { this.$.keyboardShortcuts.close(); } _handleAccountDetailUpdate(e) { this.$.mainHeader.reload(); if (this.params.view === Gerrit.Nav.View.SETTINGS) { this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail(); } } _handleRegistrationDialogClose(e) { this.params.justRegistered = false; this.$.registrationOverlay.close(); } _goToOpenedChanges() { Gerrit.Nav.navigateToStatusSearch('open'); } _goToUserDashboard() { Gerrit.Nav.navigateToUserDashboard(); } _goToMergedChanges() { Gerrit.Nav.navigateToStatusSearch('merged'); } _goToAbandonedChanges() { Gerrit.Nav.navigateToStatusSearch('abandoned'); } _goToWatchedChanges() { // The query is hardcoded, and doesn't respect custom menu entries Gerrit.Nav.navigateToSearchQuery('is:watched is:open'); } _computePluginScreenName({plugin, screen}) { if (!plugin || !screen) return ''; return `${plugin}-screen-${screen}`; } _logWelcome() { console.group('Runtime Info'); console.log('Gerrit UI (PolyGerrit)'); console.log(`Gerrit Server Version: ${this._version}`); if (window.VERSION_INFO) { console.log(`UI Version Info: ${window.VERSION_INFO}`); } if (this._feedbackUrl) { console.log(`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) { this.$.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed); } _mobileSearchToggle(e) { 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'; } } customElements.define(GrAppElement.is, GrAppElement);