blob: 6d232f8223a99fd93baa84c6c5a771fdd38830d2 [file] [log] [blame]
/**
* @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/bundled-polymer.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-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-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';
/**
* @extends Polymer.Element
*/
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',
};
}
/** @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 = 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');
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 === 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 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 = [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 = '';
}
}
_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);