| /** |
| * @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.js'; |
| import '../styles/themes/app-theme.js'; |
| import {applyTheme as applyDarkTheme} from '../styles/themes/dark-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-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-endpoint-slot/gr-endpoint-slot.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 {htmlTemplate} from './gr-app-element_html.js'; |
| import {BaseUrlBehavior} from '../behaviors/base-url-behavior/base-url-behavior.js'; |
| import {KeyboardShortcutBehavior} from '../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js'; |
| import {GerritNav} from './core/gr-navigation/gr-navigation.js'; |
| import {appContext} from '../services/app-context.js'; |
| |
| /** |
| * @extends PolymerElement |
| */ |
| class GrAppElement extends mixinBehaviors( [ |
| BaseUrlBehavior, |
| 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', |
| }; |
| } |
| |
| constructor() { |
| super(); |
| this.reporting = appContext.reportingService; |
| } |
| |
| /** @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; |
| const role = account ? 'user' : 'guest'; |
| this.reporting.reportLifeCycle(`Started as ${role}`); |
| }); |
| 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')) { |
| 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, |
| 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:keyup'); |
| this.bindShortcut( |
| this.Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup'); |
| 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.DIFF_AGAINST_BASE, this.V_KEY, 'down', 's'); |
| this.bindShortcut( |
| this.Shortcut.DIFF_AGAINST_LATEST, this.V_KEY, 'up', 'w'); |
| this.bindShortcut( |
| this.Shortcut.DIFF_BASE_AGAINST_LEFT, this.V_KEY, 'left', 'a'); |
| this.bindShortcut( |
| this.Shortcut.DIFF_RIGHT_AGAINST_LATEST, this.V_KEY, 'right', 'd'); |
| this.bindShortcut( |
| this.Shortcut.DIFF_BASE_AGAINST_LATEST, this.V_KEY, 'b'); |
| |
| 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.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h'); |
| |
| 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 === GerritNav.View.SEARCH); |
| this.set('_showDashboardView', view === GerritNav.View.DASHBOARD); |
| this.set('_showChangeView', view === GerritNav.View.CHANGE); |
| this.set('_showDiffView', view === GerritNav.View.DIFF); |
| this.set('_showSettingsView', view === GerritNav.View.SETTINGS); |
| this.set('_showAdminView', view === GerritNav.View.ADMIN || |
| view === GerritNav.View.GROUP || view === GerritNav.View.REPO); |
| this.set('_showCLAView', view === GerritNav.View.AGREEMENTS); |
| this.set('_showEditorView', view === GerritNav.View.EDIT); |
| const isPluginScreen = view === GerritNav.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 === GerritNav.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 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); |
| } |
| } |
| |
| _paramsChanged(paramsRecord) { |
| const params = paramsRecord.base; |
| const viewsToCheck = [GerritNav.View.SEARCH, GerritNav.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 = ''; |
| } |
| } |
| |
| handleShowKeyboardShortcuts() { |
| this.$.keyboardShortcuts.open(); |
| } |
| |
| _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 === GerritNav.View.SETTINGS) { |
| this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail(); |
| } |
| } |
| |
| _handleRegistrationDialogClose(e) { |
| this.params.justRegistered = false; |
| this.$.registrationOverlay.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({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); |