Move ShortcutManager to services/ directory
- Rename ShortcutManager to ShortcutsService.
- Add new service to appcontext.
- Convert keyboard mixin test to TypeScript.
- Split off service related tests from the keyboard mixin test.
- Split off the global shortcut config map into its own file.
Google-Bug-Id: b/199305453
Change-Id: I8a70ca146e1b4cd32341681c9eb04071e80219b2
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 40434a9..111d112 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -105,13 +105,23 @@
import {getKeyboardEvent, isModifierPressed} from '../../utils/dom-util';
import {CustomKeyboardEvent} from '../../types/events';
import {appContext} from '../../services/app-context';
+import {
+ Shortcut,
+ ShortcutSection,
+} from '../../services/shortcuts/shortcuts-config';
+import {
+ ShortcutListener,
+ SPECIAL_SHORTCUT,
+ SectionView,
+} from '../../services/shortcuts/shortcuts-service';
-/** Enum for all special shortcuts */
-export enum SPECIAL_SHORTCUT {
- DOC_ONLY = 'DOC_ONLY',
- GO_KEY = 'GO_KEY',
- V_KEY = 'V_KEY',
-}
+export {
+ Shortcut,
+ ShortcutSection,
+ SPECIAL_SHORTCUT,
+ ShortcutListener,
+ SectionView,
+};
// The maximum age of a keydown event to be used in a jump navigation. This
// is only for cases when the keyup event is lost.
@@ -119,631 +129,6 @@
const V_KEY_TIMEOUT_MS = 1000;
-/**
- * Enum for all shortcut sections, where that shortcut should be applied to.
- */
-export enum ShortcutSection {
- ACTIONS = 'Actions',
- DIFFS = 'Diffs',
- EVERYWHERE = 'Global Shortcuts',
- FILE_LIST = 'File list',
- NAVIGATION = 'Navigation',
- REPLY_DIALOG = 'Reply dialog',
-}
-
-/**
- * Enum for all possible shortcut names.
- */
-export enum Shortcut {
- OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
- GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
- GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
- GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
- GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
- GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
-
- CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
- CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
- OPEN_CHANGE = 'OPEN_CHANGE',
- NEXT_PAGE = 'NEXT_PAGE',
- PREV_PAGE = 'PREV_PAGE',
- TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
- TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
- REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
- OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
- TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
-
- OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
- OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
- EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
- COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
- UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
- UP_TO_CHANGE = 'UP_TO_CHANGE',
- TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
- REFRESH_CHANGE = 'REFRESH_CHANGE',
- EDIT_TOPIC = 'EDIT_TOPIC',
- DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
- DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
- DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
- DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
- DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
-
- NEXT_LINE = 'NEXT_LINE',
- PREV_LINE = 'PREV_LINE',
- VISIBLE_LINE = 'VISIBLE_LINE',
- NEXT_CHUNK = 'NEXT_CHUNK',
- PREV_CHUNK = 'PREV_CHUNK',
- TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
- NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
- PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
- EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
- COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
- LEFT_PANE = 'LEFT_PANE',
- RIGHT_PANE = 'RIGHT_PANE',
- TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
- NEW_COMMENT = 'NEW_COMMENT',
- SAVE_COMMENT = 'SAVE_COMMENT',
- OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
- TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
-
- NEXT_FILE = 'NEXT_FILE',
- PREV_FILE = 'PREV_FILE',
- NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
- PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
- NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
- CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
- CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
- OPEN_FILE = 'OPEN_FILE',
- TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
- TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
- TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
- TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
- OPEN_FILE_LIST = 'OPEN_FILE_LIST',
-
- OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
- OPEN_LAST_FILE = 'OPEN_LAST_FILE',
-
- SEARCH = 'SEARCH',
- SEND_REPLY = 'SEND_REPLY',
- EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
- TOGGLE_BLAME = 'TOGGLE_BLAME',
-}
-
-export type SectionView = Array<{binding: string[][]; text: string}>;
-
-/**
- * The interface for listener for shortcut events.
- */
-export type ShortcutListener = (
- viewMap?: Map<ShortcutSection, SectionView>
-) => void;
-
-interface ShortcutHelpItem {
- shortcut: Shortcut;
- text: string;
-}
-
-// TODO(TS): rename to something more meaningful
-const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
-
-function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
- if (!_help.has(section)) {
- _help.set(section, []);
- }
- const shortcuts = _help.get(section);
- if (shortcuts) {
- shortcuts.push({shortcut, text});
- }
-}
-
-_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-_describe(
- Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
- ShortcutSection.EVERYWHERE,
- 'Show this dialog'
-);
-_describe(
- Shortcut.GO_TO_USER_DASHBOARD,
- ShortcutSection.EVERYWHERE,
- 'Go to User Dashboard'
-);
-_describe(
- Shortcut.GO_TO_OPENED_CHANGES,
- ShortcutSection.EVERYWHERE,
- 'Go to Opened Changes'
-);
-_describe(
- Shortcut.GO_TO_MERGED_CHANGES,
- ShortcutSection.EVERYWHERE,
- 'Go to Merged Changes'
-);
-_describe(
- Shortcut.GO_TO_ABANDONED_CHANGES,
- ShortcutSection.EVERYWHERE,
- 'Go to Abandoned Changes'
-);
-_describe(
- Shortcut.GO_TO_WATCHED_CHANGES,
- ShortcutSection.EVERYWHERE,
- 'Go to Watched Changes'
-);
-
-_describe(
- Shortcut.CURSOR_NEXT_CHANGE,
- ShortcutSection.ACTIONS,
- 'Select next change'
-);
-_describe(
- Shortcut.CURSOR_PREV_CHANGE,
- ShortcutSection.ACTIONS,
- 'Select previous change'
-);
-_describe(
- Shortcut.OPEN_CHANGE,
- ShortcutSection.ACTIONS,
- 'Show selected change'
-);
-_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-_describe(
- Shortcut.OPEN_REPLY_DIALOG,
- ShortcutSection.ACTIONS,
- 'Open reply dialog to publish comments and add reviewers'
-);
-_describe(
- Shortcut.OPEN_DOWNLOAD_DIALOG,
- ShortcutSection.ACTIONS,
- 'Open download overlay'
-);
-_describe(
- Shortcut.EXPAND_ALL_MESSAGES,
- ShortcutSection.ACTIONS,
- 'Expand all messages'
-);
-_describe(
- Shortcut.COLLAPSE_ALL_MESSAGES,
- ShortcutSection.ACTIONS,
- 'Collapse all messages'
-);
-_describe(
- Shortcut.REFRESH_CHANGE,
- ShortcutSection.ACTIONS,
- 'Reload the change at the latest patch'
-);
-_describe(
- Shortcut.TOGGLE_CHANGE_REVIEWED,
- ShortcutSection.ACTIONS,
- 'Mark/unmark change as reviewed'
-);
-_describe(
- Shortcut.TOGGLE_FILE_REVIEWED,
- ShortcutSection.ACTIONS,
- 'Toggle review flag on selected file'
-);
-_describe(
- Shortcut.REFRESH_CHANGE_LIST,
- ShortcutSection.ACTIONS,
- 'Refresh list of changes'
-);
-_describe(
- Shortcut.TOGGLE_CHANGE_STAR,
- ShortcutSection.ACTIONS,
- 'Star/unstar change'
-);
-_describe(
- Shortcut.OPEN_SUBMIT_DIALOG,
- ShortcutSection.ACTIONS,
- 'Open submit dialog'
-);
-_describe(
- Shortcut.TOGGLE_ATTENTION_SET,
- ShortcutSection.ACTIONS,
- 'Toggle attention set status'
-);
-_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
-_describe(
- Shortcut.DIFF_AGAINST_BASE,
- ShortcutSection.ACTIONS,
- 'Diff against base'
-);
-_describe(
- Shortcut.DIFF_AGAINST_LATEST,
- ShortcutSection.ACTIONS,
- 'Diff against latest patchset'
-);
-_describe(
- Shortcut.DIFF_BASE_AGAINST_LEFT,
- ShortcutSection.ACTIONS,
- 'Diff base against left'
-);
-_describe(
- Shortcut.DIFF_RIGHT_AGAINST_LATEST,
- ShortcutSection.ACTIONS,
- 'Diff right against latest'
-);
-_describe(
- Shortcut.DIFF_BASE_AGAINST_LATEST,
- ShortcutSection.ACTIONS,
- 'Diff base against latest'
-);
-
-_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-_describe(
- Shortcut.DIFF_AGAINST_BASE,
- ShortcutSection.DIFFS,
- 'Diff against base'
-);
-_describe(
- Shortcut.DIFF_AGAINST_LATEST,
- ShortcutSection.DIFFS,
- 'Diff against latest patchset'
-);
-_describe(
- Shortcut.DIFF_BASE_AGAINST_LEFT,
- ShortcutSection.DIFFS,
- 'Diff base against left'
-);
-_describe(
- Shortcut.DIFF_RIGHT_AGAINST_LATEST,
- ShortcutSection.DIFFS,
- 'Diff right against latest'
-);
-_describe(
- Shortcut.DIFF_BASE_AGAINST_LATEST,
- ShortcutSection.DIFFS,
- 'Diff base against latest'
-);
-_describe(
- Shortcut.VISIBLE_LINE,
- ShortcutSection.DIFFS,
- 'Move cursor to currently visible code'
-);
-_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
-_describe(
- Shortcut.PREV_CHUNK,
- ShortcutSection.DIFFS,
- 'Go to previous diff chunk'
-);
-_describe(
- Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
- ShortcutSection.DIFFS,
- 'Toggle all diff context'
-);
-_describe(
- Shortcut.NEXT_COMMENT_THREAD,
- ShortcutSection.DIFFS,
- 'Go to next comment thread'
-);
-_describe(
- Shortcut.PREV_COMMENT_THREAD,
- ShortcutSection.DIFFS,
- 'Go to previous comment thread'
-);
-_describe(
- Shortcut.EXPAND_ALL_COMMENT_THREADS,
- ShortcutSection.DIFFS,
- 'Expand all comment threads'
-);
-_describe(
- Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
- ShortcutSection.DIFFS,
- 'Collapse all comment threads'
-);
-_describe(
- Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
- ShortcutSection.DIFFS,
- 'Hide/Display all comment threads'
-);
-_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-_describe(
- Shortcut.TOGGLE_LEFT_PANE,
- ShortcutSection.DIFFS,
- 'Hide/show left diff'
-);
-_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-_describe(
- Shortcut.OPEN_DIFF_PREFS,
- ShortcutSection.DIFFS,
- 'Show diff preferences'
-);
-_describe(
- Shortcut.TOGGLE_DIFF_REVIEWED,
- ShortcutSection.DIFFS,
- 'Mark/unmark file as reviewed'
-);
-_describe(
- Shortcut.TOGGLE_DIFF_MODE,
- ShortcutSection.DIFFS,
- 'Toggle unified/side-by-side diff'
-);
-_describe(
- Shortcut.NEXT_UNREVIEWED_FILE,
- ShortcutSection.DIFFS,
- 'Mark file as reviewed and go to next unreviewed file'
-);
-_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
-
-_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
-_describe(
- Shortcut.PREV_FILE,
- ShortcutSection.NAVIGATION,
- 'Go to previous file'
-);
-_describe(
- Shortcut.NEXT_FILE_WITH_COMMENTS,
- ShortcutSection.NAVIGATION,
- 'Go to next file that has comments'
-);
-_describe(
- Shortcut.PREV_FILE_WITH_COMMENTS,
- ShortcutSection.NAVIGATION,
- 'Go to previous file that has comments'
-);
-_describe(
- Shortcut.OPEN_FIRST_FILE,
- ShortcutSection.NAVIGATION,
- 'Go to first file'
-);
-_describe(
- Shortcut.OPEN_LAST_FILE,
- ShortcutSection.NAVIGATION,
- 'Go to last file'
-);
-_describe(
- Shortcut.UP_TO_DASHBOARD,
- ShortcutSection.NAVIGATION,
- 'Up to dashboard'
-);
-_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-_describe(
- Shortcut.CURSOR_NEXT_FILE,
- ShortcutSection.FILE_LIST,
- 'Select next file'
-);
-_describe(
- Shortcut.CURSOR_PREV_FILE,
- ShortcutSection.FILE_LIST,
- 'Select previous file'
-);
-_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
-_describe(
- Shortcut.TOGGLE_ALL_INLINE_DIFFS,
- ShortcutSection.FILE_LIST,
- 'Show/hide all inline diffs'
-);
-_describe(
- Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
- ShortcutSection.FILE_LIST,
- 'Hide/Display all comment threads'
-);
-_describe(
- Shortcut.TOGGLE_INLINE_DIFF,
- ShortcutSection.FILE_LIST,
- 'Show/hide selected inline diff'
-);
-
-_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-_describe(
- Shortcut.EMOJI_DROPDOWN,
- ShortcutSection.REPLY_DIALOG,
- 'Emoji dropdown'
-);
-
-/**
- * Shortcut manager, holds all hosts, bindings and listeners.
- */
-export class ShortcutManager {
- private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
-
- private readonly bindings = new Map<Shortcut, string[]>();
-
- public _testOnly_getBindings() {
- return this.bindings;
- }
-
- public _testOnly_isEmpty() {
- return this.activeHosts.size === 0 && this.listeners.size === 0;
- }
-
- private readonly listeners = new Set<ShortcutListener>();
-
- bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
- this.bindings.set(shortcut, bindings);
- }
-
- getBindingsForShortcut(shortcut: Shortcut) {
- return this.bindings.get(shortcut);
- }
-
- attachHost(host: PolymerElement, shortcuts: Map<string, string>) {
- this.activeHosts.set(host, shortcuts);
- this.notifyListeners();
- }
-
- detachHost(host: PolymerElement) {
- if (this.activeHosts.delete(host)) {
- this.notifyListeners();
- return true;
- }
- return false;
- }
-
- addListener(listener: ShortcutListener) {
- this.listeners.add(listener);
- listener(this.directoryView());
- }
-
- removeListener(listener: ShortcutListener) {
- return this.listeners.delete(listener);
- }
-
- getDescription(section: ShortcutSection, shortcutName: Shortcut) {
- const bindings = _help.get(section);
- let desc = '';
- if (bindings) {
- const binding = bindings.find(
- binding => binding.shortcut === shortcutName
- );
- desc = binding ? binding.text : '';
- }
- return desc;
- }
-
- getShortcut(shortcutName: Shortcut) {
- const bindings = this.bindings.get(shortcutName);
- return bindings
- ? bindings
- .map(binding => this.describeBinding(binding).join('+'))
- .join(',')
- : '';
- }
-
- activeShortcutsBySection() {
- const activeShortcuts = new Set<string>();
- this.activeHosts.forEach(shortcuts => {
- shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
- });
-
- const activeShortcutsBySection = new Map<
- ShortcutSection,
- ShortcutHelpItem[]
- >();
- _help.forEach((shortcutList, section) => {
- shortcutList.forEach(shortcutHelp => {
- if (activeShortcuts.has(shortcutHelp.shortcut)) {
- if (!activeShortcutsBySection.has(section)) {
- activeShortcutsBySection.set(section, []);
- }
- // From previous condition, the `get(section)`
- // should always return a valid result
- activeShortcutsBySection.get(section)!.push(shortcutHelp);
- }
- });
- });
- return activeShortcutsBySection;
- }
-
- directoryView() {
- const view = new Map<ShortcutSection, SectionView>();
- this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
- const sectionView: Array<{binding: string[][]; text: string}> = [];
- shortcutHelps.forEach(shortcutHelp => {
- const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
- if (!bindingDesc) {
- return;
- }
- this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
- sectionView.push({
- binding: bindingDesc,
- text: shortcutHelp.text,
- });
- });
- });
- view.set(section, sectionView);
- });
- return view;
- }
-
- distributeBindingDesc(bindingDesc: string[][]): string[][][] {
- if (
- bindingDesc.length === 1 ||
- this.comboSetDisplayWidth(bindingDesc) < 21
- ) {
- return [bindingDesc];
- }
- // Find the largest prefix of bindings that is under the
- // size threshold.
- const head = [bindingDesc[0]];
- for (let i = 1; i < bindingDesc.length; i++) {
- head.push(bindingDesc[i]);
- if (this.comboSetDisplayWidth(head) >= 21) {
- head.pop();
- return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
- }
- }
- return [];
- }
-
- comboSetDisplayWidth(bindingDesc: string[][]) {
- const bindingSizer = (binding: string[]) =>
- binding.reduce((acc, key) => acc + key.length, 0);
- // Width is the sum of strings + (n-1) * 2 to account for the word
- // "or" joining them.
- return (
- bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
- 2 * (bindingDesc.length - 1)
- );
- }
-
- describeBindings(shortcut: Shortcut): string[][] | null {
- const bindings = this.bindings.get(shortcut);
- if (!bindings) {
- return null;
- }
- if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
- return bindings
- .slice(1)
- .map(binding => this._describeKey(binding))
- .map(binding => ['g'].concat(binding));
- }
- if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
- return bindings
- .slice(1)
- .map(binding => this._describeKey(binding))
- .map(binding => ['v'].concat(binding));
- }
-
- return bindings
- .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
- .map(binding => this.describeBinding(binding));
- }
-
- _describeKey(key: string) {
- switch (key) {
- case 'shift':
- return 'Shift';
- case 'meta':
- return 'Meta';
- case 'ctrl':
- return 'Ctrl';
- case 'enter':
- return 'Enter';
- case 'up':
- return '\u2191'; // ↑
- case 'down':
- return '\u2193'; // ↓
- case 'left':
- return '\u2190'; // ←
- case 'right':
- return '\u2192'; // →
- default:
- return key;
- }
- }
-
- describeBinding(binding: string) {
- // single key bindings
- if (binding.length === 1) {
- return [binding];
- }
- return binding
- .split(':')[0]
- .split('+')
- .map(part => this._describeKey(part));
- }
-
- notifyListeners() {
- const view = this.directoryView();
- this.listeners.forEach(listener => listener(view));
- }
-}
-
-const shortcutManager = new ShortcutManager();
-
interface IronA11yKeysMixinConstructor {
// Note: this is needed to have same interface as other mixins
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -783,7 +168,9 @@
private readonly restApiService = appContext.restApiService;
- private reporting = appContext.reportingService;
+ private readonly reporting = appContext.reportingService;
+
+ private readonly shortcuts = appContext.shortcutsService;
/** Used to disable shortcuts when the element is not visible. */
private observer?: IntersectionObserver;
@@ -861,17 +248,17 @@
}
bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
- shortcutManager.bindShortcut(shortcut, ...bindings);
+ this.shortcuts.bindShortcut(shortcut, ...bindings);
}
createTitle(shortcutName: Shortcut, section: ShortcutSection) {
- const desc = shortcutManager.getDescription(section, shortcutName);
- const shortcut = shortcutManager.getShortcut(shortcutName);
+ const desc = this.shortcuts.getDescription(section, shortcutName);
+ const shortcut = this.shortcuts.getShortcut(shortcutName);
return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
}
_addOwnKeyBindings(shortcut: Shortcut, handler: string) {
- const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+ const bindings = this.shortcuts.getBindingsForShortcut(shortcut);
if (!bindings) {
return;
}
@@ -947,7 +334,7 @@
const shortcuts = new Map<string, string>(
Object.entries(this.keyboardShortcuts())
);
- shortcutManager.attachHost(this, shortcuts);
+ this.shortcuts.attachHost(this, shortcuts);
for (const [key, value] of shortcuts.entries()) {
this._addOwnKeyBindings(key as Shortcut, value);
@@ -983,7 +370,7 @@
private disableBindings() {
if (!this.bindingsEnabled) return;
this.bindingsEnabled = false;
- if (shortcutManager.detachHost(this)) {
+ if (this.shortcuts.detachHost(this)) {
this.removeOwnKeyBindings();
}
}
@@ -997,11 +384,11 @@
}
addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
- shortcutManager.addListener(listener);
+ this.shortcuts.addListener(listener);
}
removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
- shortcutManager.removeListener(listener);
+ this.shortcuts.removeListener(listener);
}
_handleVKeyDown(e: CustomKeyboardEvent) {
@@ -1077,7 +464,10 @@
}
}
- return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
+ return Mixin as T &
+ Constructor<
+ KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
+ >;
};
// The following doesn't work (IronA11yKeysBehavior crashes):
@@ -1090,7 +480,10 @@
// This is a workaround
export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> =>
+): T &
+ Constructor<
+ KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
+ > =>
InternalKeyboardShortcutMixin(
// TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
// which will fail the type check due to missing IronA11yKeysBehavior interface
@@ -1109,6 +502,10 @@
removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
}
-export function _testOnly_getShortcutManagerInstance() {
- return shortcutManager;
+export interface KeyboardShortcutMixinInterfaceTesting {
+ _shortcut_go_key_last_pressed: number | null;
+ _shortcut_v_key_last_pressed: number | null;
+ _shortcut_go_table: Map<string, string>;
+ _shortcut_v_table: Map<string, string>;
+ _handleGoAction: (e: CustomKeyboardEvent) => void;
}
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
deleted file mode 100644
index 4536ecd..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ /dev/null
@@ -1,424 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../../test/common-test-setup-karma.js';
-import {
- KeyboardShortcutMixin, Shortcut,
- ShortcutManager, ShortcutSection, SPECIAL_SHORTCUT,
-} from './keyboard-shortcut-mixin.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {mockPromise} from '../../test/test-utils.js';
-
-const basicFixture =
- fixtureFromElement('keyboard-shortcut-mixin-test-element');
-
-const withinOverlayFixture = fixtureFromTemplate(html`
-<gr-overlay>
- <keyboard-shortcut-mixin-test-element>
- </keyboard-shortcut-mixin-test-element>
-</gr-overlay>
-`);
-
-class GrKeyboardShortcutMixinTestElement extends
- KeyboardShortcutMixin(PolymerElement) {
- static get is() {
- return 'keyboard-shortcut-mixin-test-element';
- }
-
- get keyBindings() {
- return {
- k: '_handleKey',
- enter: '_handleKey',
- };
- }
-
- _handleKey() {}
-}
-
-customElements.define(GrKeyboardShortcutMixinTestElement.is,
- GrKeyboardShortcutMixinTestElement);
-
-suite('keyboard-shortcut-mixin tests', () => {
- let element;
- let overlay;
-
- setup(() => {
- element = basicFixture.instantiate();
- overlay = withinOverlayFixture.instantiate();
- });
-
- suite('ShortcutManager', () => {
- test('bindings management', () => {
- const mgr = new ShortcutManager();
- const NEXT_FILE = Shortcut.NEXT_FILE;
-
- assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
- mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
- assert.deepEqual(
- mgr.getBindingsForShortcut(NEXT_FILE),
- [']', '}', 'right']);
- });
-
- test('getShortcut', () => {
- const mgr = new ShortcutManager();
- const NEXT_FILE = Shortcut.NEXT_FILE;
-
- assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
- mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
- assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
- });
-
- test('getShortcut with modifiers', () => {
- const mgr = new ShortcutManager();
- const NEXT_FILE = Shortcut.NEXT_FILE;
-
- assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
- mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
- assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
- });
-
- suite('binding descriptions', () => {
- function mapToObject(m) {
- const o = {};
- m.forEach((v, k) => o[k] = v);
- return o;
- }
-
- test('single combo description', () => {
- const mgr = new ShortcutManager();
- assert.deepEqual(mgr.describeBinding('a'), ['a']);
- assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
- assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
- assert.deepEqual(
- mgr.describeBinding('ctrl+shift+up:keyup'),
- ['Ctrl', 'Shift', '↑']);
- });
-
- test('combo set description', () => {
- const mgr = new ShortcutManager();
- assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
-
- mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
- SPECIAL_SHORTCUT.GO_KEY, 'o');
- assert.deepEqual(
- mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
- [['g', 'o']]);
-
- mgr.bindShortcut(Shortcut.NEXT_FILE, SPECIAL_SHORTCUT.DOC_ONLY,
- ']', 'ctrl+shift+right:keyup');
- assert.deepEqual(
- mgr.describeBindings(Shortcut.NEXT_FILE),
- [[']'], ['Ctrl', 'Shift', '→']]);
-
- mgr.bindShortcut(Shortcut.PREV_FILE, '[');
- assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
- });
-
- test('combo set description width', () => {
- const mgr = new ShortcutManager();
- assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
- assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
- assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
- assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
- assert.strictEqual(
- mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
- 12);
- });
-
- test('distribute shortcut help', () => {
- const mgr = new ShortcutManager();
- assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
- assert.deepEqual(
- mgr.distributeBindingDesc([['g', 'o']]),
- [[['g', 'o']]]);
- assert.deepEqual(
- mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
- [[['ctrl', 'shift', 'meta', 'enter']]]);
- assert.deepEqual(
- mgr.distributeBindingDesc([
- ['ctrl', 'shift', 'meta', 'enter'],
- ['o'],
- ]),
- [
- [['ctrl', 'shift', 'meta', 'enter']],
- [['o']],
- ]);
- assert.deepEqual(
- mgr.distributeBindingDesc([
- ['ctrl', 'enter'],
- ['meta', 'enter'],
- ['ctrl', 's'],
- ['meta', 's'],
- ]),
- [
- [['ctrl', 'enter'], ['meta', 'enter']],
- [['ctrl', 's'], ['meta', 's']],
- ]);
- });
-
- test('active shortcuts by section', () => {
- const mgr = new ShortcutManager();
- mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
- mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
- mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
- mgr.bindShortcut(Shortcut.SEARCH, '/');
-
- assert.deepEqual(
- mapToObject(mgr.activeShortcutsBySection()),
- {});
-
- mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, null]]));
- assert.deepEqual(
- mapToObject(mgr.activeShortcutsBySection()),
- {
- [ShortcutSection.NAVIGATION]: [
- {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
- ],
- });
-
- mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, null]]));
- assert.deepEqual(
- mapToObject(mgr.activeShortcutsBySection()),
- {
- [ShortcutSection.DIFFS]: [
- {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
- ],
- [ShortcutSection.NAVIGATION]: [
- {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
- ],
- });
-
- mgr.attachHost({}, new Map([
- [Shortcut.SEARCH, null],
- [Shortcut.GO_TO_OPENED_CHANGES, null],
- ]));
- assert.deepEqual(
- mapToObject(mgr.activeShortcutsBySection()),
- {
- [ShortcutSection.DIFFS]: [
- {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
- ],
- [ShortcutSection.EVERYWHERE]: [
- {shortcut: Shortcut.SEARCH, text: 'Search'},
- {
- shortcut: Shortcut.GO_TO_OPENED_CHANGES,
- text: 'Go to Opened Changes',
- },
- ],
- [ShortcutSection.NAVIGATION]: [
- {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
- ],
- });
- });
-
- test('directory view', () => {
- const mgr = new ShortcutManager();
- mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
- mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
- mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
- SPECIAL_SHORTCUT.GO_KEY, 'o');
- mgr.bindShortcut(Shortcut.SEARCH, '/');
- mgr.bindShortcut(
- Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter',
- 'ctrl+s', 'meta+s');
-
- assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
- mgr.attachHost({}, new Map([
- [Shortcut.GO_TO_OPENED_CHANGES, null],
- [Shortcut.NEXT_FILE, null],
- [Shortcut.NEXT_LINE, null],
- [Shortcut.SAVE_COMMENT, null],
- [Shortcut.SEARCH, null],
- ]));
- assert.deepEqual(
- mapToObject(mgr.directoryView()),
- {
- [ShortcutSection.DIFFS]: [
- {binding: [['j']], text: 'Go to next line'},
- {
- binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
- text: 'Save comment',
- },
- {
- binding: [['Ctrl', 's'], ['Meta', 's']],
- text: 'Save comment',
- },
- ],
- [ShortcutSection.EVERYWHERE]: [
- {binding: [['/']], text: 'Search'},
- {binding: [['g', 'o']], text: 'Go to Opened Changes'},
- ],
- [ShortcutSection.NAVIGATION]: [
- {binding: [[']']], text: 'Go to next file'},
- ],
- });
- });
- });
- });
-
- test('doesn’t block kb shortcuts for non-allowed els', async () => {
- const divEl = document.createElement('div');
- element.appendChild(divEl);
- const promise = mockPromise();
- element._handleKey = e => {
- assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
- promise.resolve();
- };
- MockInteractions.keyDownOn(divEl, 75, null, 'k');
- await promise;
- });
-
- test('blocks kb shortcuts for input els', async () => {
- const inputEl = document.createElement('input');
- element.appendChild(inputEl);
- const promise = mockPromise();
- element._handleKey = e => {
- assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
- promise.resolve();
- };
- MockInteractions.keyDownOn(inputEl, 75, null, 'k');
- await promise;
- });
-
- test('doesn’t block kb shortcuts for checkboxes', async () => {
- const inputEl = document.createElement('input');
- inputEl.setAttribute('type', 'checkbox');
- element.appendChild(inputEl);
- const promise = mockPromise();
- element._handleKey = e => {
- assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
- promise.resolve();
- };
- MockInteractions.keyDownOn(inputEl, 75, null, 'k');
- await promise;
- });
-
- test('blocks kb shortcuts for textarea els', async () => {
- const textareaEl = document.createElement('textarea');
- element.appendChild(textareaEl);
- const promise = mockPromise();
- element._handleKey = e => {
- assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
- promise.resolve();
- };
- MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
- await promise;
- });
-
- test('blocks kb shortcuts for anything in a gr-overlay', async () => {
- const divEl = document.createElement('div');
- const element =
- overlay.querySelector('keyboard-shortcut-mixin-test-element');
- element.appendChild(divEl);
- const promise = mockPromise();
- element._handleKey = e => {
- assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
- promise.resolve();
- };
- MockInteractions.keyDownOn(divEl, 75, null, 'k');
- await promise;
- });
-
- test('blocks enter shortcut on an anchor', async () => {
- const anchorEl = document.createElement('a');
- const element =
- overlay.querySelector('keyboard-shortcut-mixin-test-element');
- element.appendChild(anchorEl);
- const promise = mockPromise();
- element._handleKey = e => {
- assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
- promise.resolve();
- };
- MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
- await promise;
- });
-
- test('modifierPressed returns accurate values', () => {
- const spy = sinon.spy(element, 'modifierPressed');
- element._handleKey = e => {
- element.modifierPressed(e);
- };
- MockInteractions.keyDownOn(element, 75, 'shift', 'k');
- assert.isTrue(spy.lastCall.returnValue);
- MockInteractions.keyDownOn(element, 75, null, 'k');
- assert.isFalse(spy.lastCall.returnValue);
- MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
- assert.isTrue(spy.lastCall.returnValue);
- MockInteractions.keyDownOn(element, 75, null, 'k');
- assert.isFalse(spy.lastCall.returnValue);
- MockInteractions.keyDownOn(element, 75, 'meta', 'k');
- assert.isTrue(spy.lastCall.returnValue);
- MockInteractions.keyDownOn(element, 75, null, 'k');
- assert.isFalse(spy.lastCall.returnValue);
- MockInteractions.keyDownOn(element, 75, 'alt', 'k');
- assert.isTrue(spy.lastCall.returnValue);
- });
-
- suite('GO_KEY timing', () => {
- let handlerStub;
-
- setup(() => {
- element._shortcut_go_table.set('a', '_handleA');
- handlerStub = element._handleA = sinon.stub();
- sinon.stub(Date, 'now').returns(10000);
- });
-
- test('success', () => {
- const e = {detail: {key: 'a'}, preventDefault: () => {}};
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._shortcut_go_key_last_pressed = 9000;
- element._handleGoAction(e);
- assert.isTrue(handlerStub.calledOnce);
- assert.strictEqual(handlerStub.lastCall.args[0], e);
- });
-
- test('go key not pressed', () => {
- const e = {detail: {key: 'a'}, preventDefault: () => {}};
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._shortcut_go_key_last_pressed = null;
- element._handleGoAction(e);
- assert.isFalse(handlerStub.called);
- });
-
- test('go key pressed too long ago', () => {
- const e = {detail: {key: 'a'}, preventDefault: () => {}};
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._shortcut_go_key_last_pressed = 3000;
- element._handleGoAction(e);
- assert.isFalse(handlerStub.called);
- });
-
- test('should suppress', () => {
- const e = {detail: {key: 'a'}, preventDefault: () => {}};
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
- element._shortcut_go_key_last_pressed = 9000;
- element._handleGoAction(e);
- assert.isFalse(handlerStub.called);
- });
-
- test('unrecognized key', () => {
- const e = {detail: {key: 'f'}, preventDefault: () => {}};
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._shortcut_go_key_last_pressed = 9000;
- element._handleGoAction(e);
- assert.isFalse(handlerStub.called);
- });
- });
-});
-
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
new file mode 100644
index 0000000..6350bf9
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright (C) 2016 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 '../../test/common-test-setup-karma';
+import {KeyboardShortcutMixin} from './keyboard-shortcut-mixin';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {mockPromise, queryAndAssert} from '../../test/test-utils';
+import '../../elements/shared/gr-overlay/gr-overlay';
+import {GrOverlay} from '../../elements/shared/gr-overlay/gr-overlay';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {CustomKeyboardEvent} from '../../types/events';
+
+class GrKeyboardShortcutMixinTestElement extends KeyboardShortcutMixin(
+ PolymerElement
+) {
+ static get is() {
+ return 'keyboard-shortcut-mixin-test-element';
+ }
+
+ get keyBindings() {
+ return {
+ k: '_handleKey',
+ enter: '_handleKey',
+ };
+ }
+
+ _handleKey(_: any) {}
+
+ _handleA(_: any) {}
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'keyboard-shortcut-mixin-test-element': GrKeyboardShortcutMixinTestElement;
+ }
+}
+
+customElements.define(
+ GrKeyboardShortcutMixinTestElement.is,
+ GrKeyboardShortcutMixinTestElement
+);
+
+const basicFixture = fixtureFromElement('keyboard-shortcut-mixin-test-element');
+
+const withinOverlayFixture = fixtureFromTemplate(html`
+ <gr-overlay>
+ <keyboard-shortcut-mixin-test-element>
+ </keyboard-shortcut-mixin-test-element>
+ </gr-overlay>
+`);
+
+suite('keyboard-shortcut-mixin tests', () => {
+ let element: GrKeyboardShortcutMixinTestElement;
+ let overlay: GrOverlay;
+
+ setup(async () => {
+ element = basicFixture.instantiate();
+ overlay = withinOverlayFixture.instantiate() as GrOverlay;
+ await flush();
+ });
+
+ test('doesn’t block kb shortcuts for non-allowed els', async () => {
+ const divEl = document.createElement('div');
+ element.appendChild(divEl);
+ const promise = mockPromise();
+ element._handleKey = e => {
+ assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+ promise.resolve();
+ };
+ MockInteractions.keyDownOn(divEl, 75, null, 'k');
+ await promise;
+ });
+
+ test('blocks kb shortcuts for input els', async () => {
+ const inputEl = document.createElement('input');
+ element.appendChild(inputEl);
+ const promise = mockPromise();
+ element._handleKey = e => {
+ assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+ promise.resolve();
+ };
+ MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+ await promise;
+ });
+
+ test('doesn’t block kb shortcuts for checkboxes', async () => {
+ const inputEl = document.createElement('input');
+ inputEl.setAttribute('type', 'checkbox');
+ element.appendChild(inputEl);
+ const promise = mockPromise();
+ element._handleKey = e => {
+ assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+ promise.resolve();
+ };
+ MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+ await promise;
+ });
+
+ test('blocks kb shortcuts for textarea els', async () => {
+ const textareaEl = document.createElement('textarea');
+ element.appendChild(textareaEl);
+ const promise = mockPromise();
+ element._handleKey = e => {
+ assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+ promise.resolve();
+ };
+ MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+ await promise;
+ });
+
+ test('blocks kb shortcuts for anything in a gr-overlay', async () => {
+ const divEl = document.createElement('div');
+ const element = queryAndAssert<GrKeyboardShortcutMixinTestElement>(
+ overlay,
+ 'keyboard-shortcut-mixin-test-element'
+ );
+ element.appendChild(divEl);
+ const promise = mockPromise();
+ element._handleKey = e => {
+ assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+ promise.resolve();
+ };
+ MockInteractions.keyDownOn(divEl, 75, null, 'k');
+ await promise;
+ });
+
+ test('blocks enter shortcut on an anchor', async () => {
+ const anchorEl = document.createElement('a');
+ const element = queryAndAssert<GrKeyboardShortcutMixinTestElement>(
+ overlay,
+ 'keyboard-shortcut-mixin-test-element'
+ );
+ element.appendChild(anchorEl);
+ const promise = mockPromise();
+ element._handleKey = e => {
+ assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+ promise.resolve();
+ };
+ MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+ await promise;
+ });
+
+ test('modifierPressed returns accurate values', () => {
+ const spy = sinon.spy(element, 'modifierPressed');
+ element._handleKey = e => {
+ element.modifierPressed(e);
+ };
+ MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+ assert.isTrue(spy.lastCall.returnValue);
+ MockInteractions.keyDownOn(element, 75, null, 'k');
+ assert.isFalse(spy.lastCall.returnValue);
+ MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+ assert.isTrue(spy.lastCall.returnValue);
+ MockInteractions.keyDownOn(element, 75, null, 'k');
+ assert.isFalse(spy.lastCall.returnValue);
+ MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+ assert.isTrue(spy.lastCall.returnValue);
+ MockInteractions.keyDownOn(element, 75, null, 'k');
+ assert.isFalse(spy.lastCall.returnValue);
+ MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+ assert.isTrue(spy.lastCall.returnValue);
+ });
+
+ suite('GO_KEY timing', () => {
+ let handlerStub: sinon.SinonStub;
+
+ setup(() => {
+ element._shortcut_go_table.set('a', '_handleA');
+ handlerStub = element._handleA = sinon.stub();
+ sinon.stub(Date, 'now').returns(10000);
+ });
+
+ test('success', () => {
+ const e = {
+ detail: {key: 'a'},
+ preventDefault: () => {},
+ } as CustomKeyboardEvent;
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._shortcut_go_key_last_pressed = 9000;
+ element._handleGoAction(e);
+ assert.isTrue(handlerStub.calledOnce);
+ assert.strictEqual(handlerStub.lastCall.args[0], e);
+ });
+
+ test('go key not pressed', () => {
+ const e = {
+ detail: {key: 'a'},
+ preventDefault: () => {},
+ } as CustomKeyboardEvent;
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._shortcut_go_key_last_pressed = null;
+ element._handleGoAction(e);
+ assert.isFalse(handlerStub.called);
+ });
+
+ test('go key pressed too long ago', () => {
+ const e = {
+ detail: {key: 'a'},
+ preventDefault: () => {},
+ } as CustomKeyboardEvent;
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._shortcut_go_key_last_pressed = 3000;
+ element._handleGoAction(e);
+ assert.isFalse(handlerStub.called);
+ });
+
+ test('should suppress', () => {
+ const e = {
+ detail: {key: 'a'},
+ preventDefault: () => {},
+ } as CustomKeyboardEvent;
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+ element._shortcut_go_key_last_pressed = 9000;
+ element._handleGoAction(e);
+ assert.isFalse(handlerStub.called);
+ });
+
+ test('unrecognized key', () => {
+ const e = {
+ detail: {key: 'f'},
+ preventDefault: () => {},
+ } as CustomKeyboardEvent;
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._shortcut_go_key_last_pressed = 9000;
+ element._handleGoAction(e);
+ assert.isFalse(handlerStub.called);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index ade9529..597776d 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -27,6 +27,7 @@
import {ConfigService} from './config/config-service';
import {UserService} from './user/user-service';
import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
type ServiceName = keyof AppContext;
type ServiceCreator<T> = () => T;
@@ -82,5 +83,6 @@
storageService: () => new GrStorageService(),
configService: () => new ConfigService(),
userService: () => new UserService(appContext.restApiService),
+ shortcutsService: () => new ShortcutsService(),
});
}
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 161378d..e5828d6 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -26,6 +26,7 @@
import {ConfigService} from './config/config-service';
import {UserService} from './user/user-service';
import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
export interface AppContext {
flagsService: FlagsService;
@@ -40,6 +41,7 @@
storageService: StorageService;
configService: ConfigService;
userService: UserService;
+ shortcutsService: ShortcutsService;
}
/**
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
new file mode 100644
index 0000000..023a9a2
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -0,0 +1,412 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+ ACTIONS = 'Actions',
+ DIFFS = 'Diffs',
+ EVERYWHERE = 'Global Shortcuts',
+ FILE_LIST = 'File list',
+ NAVIGATION = 'Navigation',
+ REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum Shortcut {
+ OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
+ GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
+ GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
+ GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
+ GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
+ GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+
+ CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
+ CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
+ OPEN_CHANGE = 'OPEN_CHANGE',
+ NEXT_PAGE = 'NEXT_PAGE',
+ PREV_PAGE = 'PREV_PAGE',
+ TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
+ TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
+ REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
+ OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
+ TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
+
+ OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
+ OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+ EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
+ COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
+ UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
+ UP_TO_CHANGE = 'UP_TO_CHANGE',
+ TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
+ REFRESH_CHANGE = 'REFRESH_CHANGE',
+ EDIT_TOPIC = 'EDIT_TOPIC',
+ DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+ DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+ DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+ DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+ DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+ NEXT_LINE = 'NEXT_LINE',
+ PREV_LINE = 'PREV_LINE',
+ VISIBLE_LINE = 'VISIBLE_LINE',
+ NEXT_CHUNK = 'NEXT_CHUNK',
+ PREV_CHUNK = 'PREV_CHUNK',
+ TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
+ NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
+ PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
+ EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
+ COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
+ LEFT_PANE = 'LEFT_PANE',
+ RIGHT_PANE = 'RIGHT_PANE',
+ TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
+ NEW_COMMENT = 'NEW_COMMENT',
+ SAVE_COMMENT = 'SAVE_COMMENT',
+ OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
+ TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
+
+ NEXT_FILE = 'NEXT_FILE',
+ PREV_FILE = 'PREV_FILE',
+ NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
+ PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
+ NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
+ CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
+ CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
+ OPEN_FILE = 'OPEN_FILE',
+ TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
+ TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
+ TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
+ TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+ OPEN_FILE_LIST = 'OPEN_FILE_LIST',
+
+ OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
+ OPEN_LAST_FILE = 'OPEN_LAST_FILE',
+
+ SEARCH = 'SEARCH',
+ SEND_REPLY = 'SEND_REPLY',
+ EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+ TOGGLE_BLAME = 'TOGGLE_BLAME',
+}
+
+export interface ShortcutHelpItem {
+ shortcut: Shortcut;
+ text: string;
+}
+
+export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
+ if (!config.has(section)) {
+ config.set(section, []);
+ }
+ const shortcuts = config.get(section);
+ if (shortcuts) {
+ shortcuts.push({shortcut, text});
+ }
+}
+
+describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
+describe(
+ Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+ ShortcutSection.EVERYWHERE,
+ 'Show this dialog'
+);
+describe(
+ Shortcut.GO_TO_USER_DASHBOARD,
+ ShortcutSection.EVERYWHERE,
+ 'Go to User Dashboard'
+);
+describe(
+ Shortcut.GO_TO_OPENED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Opened Changes'
+);
+describe(
+ Shortcut.GO_TO_MERGED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Merged Changes'
+);
+describe(
+ Shortcut.GO_TO_ABANDONED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Abandoned Changes'
+);
+describe(
+ Shortcut.GO_TO_WATCHED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Watched Changes'
+);
+
+describe(
+ Shortcut.CURSOR_NEXT_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Select next change'
+);
+describe(
+ Shortcut.CURSOR_PREV_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Select previous change'
+);
+describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS, 'Show selected change');
+describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
+describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
+describe(
+ Shortcut.OPEN_REPLY_DIALOG,
+ ShortcutSection.ACTIONS,
+ 'Open reply dialog to publish comments and add reviewers'
+);
+describe(
+ Shortcut.OPEN_DOWNLOAD_DIALOG,
+ ShortcutSection.ACTIONS,
+ 'Open download overlay'
+);
+describe(
+ Shortcut.EXPAND_ALL_MESSAGES,
+ ShortcutSection.ACTIONS,
+ 'Expand all messages'
+);
+describe(
+ Shortcut.COLLAPSE_ALL_MESSAGES,
+ ShortcutSection.ACTIONS,
+ 'Collapse all messages'
+);
+describe(
+ Shortcut.REFRESH_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Reload the change at the latest patch'
+);
+describe(
+ Shortcut.TOGGLE_CHANGE_REVIEWED,
+ ShortcutSection.ACTIONS,
+ 'Mark/unmark change as reviewed'
+);
+describe(
+ Shortcut.TOGGLE_FILE_REVIEWED,
+ ShortcutSection.ACTIONS,
+ 'Toggle review flag on selected file'
+);
+describe(
+ Shortcut.REFRESH_CHANGE_LIST,
+ ShortcutSection.ACTIONS,
+ 'Refresh list of changes'
+);
+describe(
+ Shortcut.TOGGLE_CHANGE_STAR,
+ ShortcutSection.ACTIONS,
+ 'Star/unstar change'
+);
+describe(
+ Shortcut.OPEN_SUBMIT_DIALOG,
+ ShortcutSection.ACTIONS,
+ 'Open submit dialog'
+);
+describe(
+ Shortcut.TOGGLE_ATTENTION_SET,
+ ShortcutSection.ACTIONS,
+ 'Toggle attention set status'
+);
+describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
+describe(
+ Shortcut.DIFF_AGAINST_BASE,
+ ShortcutSection.ACTIONS,
+ 'Diff against base'
+);
+describe(
+ Shortcut.DIFF_AGAINST_LATEST,
+ ShortcutSection.ACTIONS,
+ 'Diff against latest patchset'
+);
+describe(
+ Shortcut.DIFF_BASE_AGAINST_LEFT,
+ ShortcutSection.ACTIONS,
+ 'Diff base against left'
+);
+describe(
+ Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+ ShortcutSection.ACTIONS,
+ 'Diff right against latest'
+);
+describe(
+ Shortcut.DIFF_BASE_AGAINST_LATEST,
+ ShortcutSection.ACTIONS,
+ 'Diff base against latest'
+);
+
+describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+describe(
+ Shortcut.DIFF_AGAINST_BASE,
+ ShortcutSection.DIFFS,
+ 'Diff against base'
+);
+describe(
+ Shortcut.DIFF_AGAINST_LATEST,
+ ShortcutSection.DIFFS,
+ 'Diff against latest patchset'
+);
+describe(
+ Shortcut.DIFF_BASE_AGAINST_LEFT,
+ ShortcutSection.DIFFS,
+ 'Diff base against left'
+);
+describe(
+ Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+ ShortcutSection.DIFFS,
+ 'Diff right against latest'
+);
+describe(
+ Shortcut.DIFF_BASE_AGAINST_LATEST,
+ ShortcutSection.DIFFS,
+ 'Diff base against latest'
+);
+describe(
+ Shortcut.VISIBLE_LINE,
+ ShortcutSection.DIFFS,
+ 'Move cursor to currently visible code'
+);
+describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
+describe(
+ Shortcut.PREV_CHUNK,
+ ShortcutSection.DIFFS,
+ 'Go to previous diff chunk'
+);
+describe(
+ Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
+ ShortcutSection.DIFFS,
+ 'Toggle all diff context'
+);
+describe(
+ Shortcut.NEXT_COMMENT_THREAD,
+ ShortcutSection.DIFFS,
+ 'Go to next comment thread'
+);
+describe(
+ Shortcut.PREV_COMMENT_THREAD,
+ ShortcutSection.DIFFS,
+ 'Go to previous comment thread'
+);
+describe(
+ Shortcut.EXPAND_ALL_COMMENT_THREADS,
+ ShortcutSection.DIFFS,
+ 'Expand all comment threads'
+);
+describe(
+ Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+ ShortcutSection.DIFFS,
+ 'Collapse all comment threads'
+);
+describe(
+ Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+ ShortcutSection.DIFFS,
+ 'Hide/Display all comment threads'
+);
+describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
+describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
+describe(
+ Shortcut.TOGGLE_LEFT_PANE,
+ ShortcutSection.DIFFS,
+ 'Hide/show left diff'
+);
+describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
+describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
+describe(
+ Shortcut.OPEN_DIFF_PREFS,
+ ShortcutSection.DIFFS,
+ 'Show diff preferences'
+);
+describe(
+ Shortcut.TOGGLE_DIFF_REVIEWED,
+ ShortcutSection.DIFFS,
+ 'Mark/unmark file as reviewed'
+);
+describe(
+ Shortcut.TOGGLE_DIFF_MODE,
+ ShortcutSection.DIFFS,
+ 'Toggle unified/side-by-side diff'
+);
+describe(
+ Shortcut.NEXT_UNREVIEWED_FILE,
+ ShortcutSection.DIFFS,
+ 'Mark file as reviewed and go to next unreviewed file'
+);
+describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
+
+describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
+describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION, 'Go to previous file');
+describe(
+ Shortcut.NEXT_FILE_WITH_COMMENTS,
+ ShortcutSection.NAVIGATION,
+ 'Go to next file that has comments'
+);
+describe(
+ Shortcut.PREV_FILE_WITH_COMMENTS,
+ ShortcutSection.NAVIGATION,
+ 'Go to previous file that has comments'
+);
+describe(
+ Shortcut.OPEN_FIRST_FILE,
+ ShortcutSection.NAVIGATION,
+ 'Go to first file'
+);
+describe(
+ Shortcut.OPEN_LAST_FILE,
+ ShortcutSection.NAVIGATION,
+ 'Go to last file'
+);
+describe(
+ Shortcut.UP_TO_DASHBOARD,
+ ShortcutSection.NAVIGATION,
+ 'Up to dashboard'
+);
+describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
+
+describe(
+ Shortcut.CURSOR_NEXT_FILE,
+ ShortcutSection.FILE_LIST,
+ 'Select next file'
+);
+describe(
+ Shortcut.CURSOR_PREV_FILE,
+ ShortcutSection.FILE_LIST,
+ 'Select previous file'
+);
+describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
+describe(
+ Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+ ShortcutSection.FILE_LIST,
+ 'Show/hide all inline diffs'
+);
+describe(
+ Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+ ShortcutSection.FILE_LIST,
+ 'Hide/Display all comment threads'
+);
+describe(
+ Shortcut.TOGGLE_INLINE_DIFF,
+ ShortcutSection.FILE_LIST,
+ 'Show/hide selected inline diff'
+);
+
+describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+describe(
+ Shortcut.EMOJI_DROPDOWN,
+ ShortcutSection.REPLY_DIALOG,
+ 'Emoji dropdown'
+);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
new file mode 100644
index 0000000..964fd7d
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright (C) 2021 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 {
+ config,
+ Shortcut,
+ ShortcutHelpItem,
+ ShortcutSection,
+} from './shortcuts-config';
+
+/** Enum for all special shortcuts */
+export enum SPECIAL_SHORTCUT {
+ DOC_ONLY = 'DOC_ONLY',
+ GO_KEY = 'GO_KEY',
+ V_KEY = 'V_KEY',
+}
+export type SectionView = Array<{binding: string[][]; text: string}>;
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutListener = (
+ viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+/**
+ * Shortcuts service, holds all hosts, bindings and listeners.
+ */
+export class ShortcutsService {
+ private readonly activeHosts = new Map<unknown, Map<string, string>>();
+
+ private readonly bindings = new Map<Shortcut, string[]>();
+
+ public _testOnly_getBindings() {
+ return this.bindings;
+ }
+
+ public _testOnly_isEmpty() {
+ return this.activeHosts.size === 0 && this.listeners.size === 0;
+ }
+
+ private readonly listeners = new Set<ShortcutListener>();
+
+ bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+ this.bindings.set(shortcut, bindings);
+ }
+
+ getBindingsForShortcut(shortcut: Shortcut) {
+ return this.bindings.get(shortcut);
+ }
+
+ attachHost(host: unknown, shortcuts: Map<string, string>) {
+ this.activeHosts.set(host, shortcuts);
+ this.notifyListeners();
+ }
+
+ detachHost(host: unknown) {
+ if (!this.activeHosts.delete(host)) return false;
+ this.notifyListeners();
+ return true;
+ }
+
+ addListener(listener: ShortcutListener) {
+ this.listeners.add(listener);
+ listener(this.directoryView());
+ }
+
+ removeListener(listener: ShortcutListener) {
+ return this.listeners.delete(listener);
+ }
+
+ getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+ const bindings = config.get(section);
+ if (!bindings) return '';
+ const binding = bindings.find(binding => binding.shortcut === shortcutName);
+ return binding?.text ?? '';
+ }
+
+ getShortcut(shortcutName: Shortcut) {
+ const bindings = this.bindings.get(shortcutName);
+ if (!bindings) return '';
+ return bindings
+ .map(binding => this.describeBinding(binding).join('+'))
+ .join(',');
+ }
+
+ activeShortcutsBySection() {
+ const activeShortcuts = new Set<string>();
+ this.activeHosts.forEach(shortcuts => {
+ shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+ });
+
+ const activeShortcutsBySection = new Map<
+ ShortcutSection,
+ ShortcutHelpItem[]
+ >();
+ config.forEach((shortcutList, section) => {
+ shortcutList.forEach(shortcutHelp => {
+ if (activeShortcuts.has(shortcutHelp.shortcut)) {
+ if (!activeShortcutsBySection.has(section)) {
+ activeShortcutsBySection.set(section, []);
+ }
+ // From previous condition, the `get(section)`
+ // should always return a valid result
+ activeShortcutsBySection.get(section)!.push(shortcutHelp);
+ }
+ });
+ });
+ return activeShortcutsBySection;
+ }
+
+ directoryView() {
+ const view = new Map<ShortcutSection, SectionView>();
+ this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+ const sectionView: SectionView = [];
+ shortcutHelps.forEach(shortcutHelp => {
+ const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+ if (!bindingDesc) {
+ return;
+ }
+ this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+ sectionView.push({
+ binding: bindingDesc,
+ text: shortcutHelp.text,
+ });
+ });
+ });
+ view.set(section, sectionView);
+ });
+ return view;
+ }
+
+ distributeBindingDesc(bindingDesc: string[][]): string[][][] {
+ if (
+ bindingDesc.length === 1 ||
+ this.comboSetDisplayWidth(bindingDesc) < 21
+ ) {
+ return [bindingDesc];
+ }
+ // Find the largest prefix of bindings that is under the
+ // size threshold.
+ const head = [bindingDesc[0]];
+ for (let i = 1; i < bindingDesc.length; i++) {
+ head.push(bindingDesc[i]);
+ if (this.comboSetDisplayWidth(head) >= 21) {
+ head.pop();
+ return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
+ }
+ }
+ return [];
+ }
+
+ comboSetDisplayWidth(bindingDesc: string[][]) {
+ const bindingSizer = (binding: string[]) =>
+ binding.reduce((acc, key) => acc + key.length, 0);
+ // Width is the sum of strings + (n-1) * 2 to account for the word
+ // "or" joining them.
+ return (
+ bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
+ 2 * (bindingDesc.length - 1)
+ );
+ }
+
+ describeBindings(shortcut: Shortcut): string[][] | null {
+ const bindings = this.bindings.get(shortcut);
+ if (!bindings) {
+ return null;
+ }
+ if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+ return bindings
+ .slice(1)
+ .map(binding => this._describeKey(binding))
+ .map(binding => ['g'].concat(binding));
+ }
+ if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+ return bindings
+ .slice(1)
+ .map(binding => this._describeKey(binding))
+ .map(binding => ['v'].concat(binding));
+ }
+
+ return bindings
+ .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
+ .map(binding => this.describeBinding(binding));
+ }
+
+ _describeKey(key: string) {
+ switch (key) {
+ case 'shift':
+ return 'Shift';
+ case 'meta':
+ return 'Meta';
+ case 'ctrl':
+ return 'Ctrl';
+ case 'enter':
+ return 'Enter';
+ case 'up':
+ return '\u2191'; // ↑
+ case 'down':
+ return '\u2193'; // ↓
+ case 'left':
+ return '\u2190'; // ←
+ case 'right':
+ return '\u2192'; // →
+ default:
+ return key;
+ }
+ }
+
+ describeBinding(binding: string) {
+ // single key bindings
+ if (binding.length === 1) {
+ return [binding];
+ }
+ return binding
+ .split(':')[0]
+ .split('+')
+ .map(part => this._describeKey(part));
+ }
+
+ notifyListeners() {
+ const view = this.directoryView();
+ this.listeners.forEach(listener => listener(view));
+ }
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
new file mode 100644
index 0000000..8586b4c
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -0,0 +1,255 @@
+/**
+ * @license
+ * Copyright (C) 2021 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 '../../test/common-test-setup-karma';
+import {
+ ShortcutsService,
+ SPECIAL_SHORTCUT,
+} from '../../services/shortcuts/shortcuts-service';
+import {Shortcut, ShortcutSection} from './shortcuts-config';
+
+suite('shortcuts-service tests', () => {
+ test('bindings management', () => {
+ const mgr = new ShortcutsService();
+ const NEXT_FILE = Shortcut.NEXT_FILE;
+
+ assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+ mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+ assert.deepEqual(mgr.getBindingsForShortcut(NEXT_FILE), [
+ ']',
+ '}',
+ 'right',
+ ]);
+ });
+
+ test('getShortcut', () => {
+ const mgr = new ShortcutsService();
+ const NEXT_FILE = Shortcut.NEXT_FILE;
+
+ assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+ mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+ assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
+ });
+
+ test('getShortcut with modifiers', () => {
+ const mgr = new ShortcutsService();
+ const NEXT_FILE = Shortcut.NEXT_FILE;
+
+ assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+ mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
+ assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
+ });
+
+ suite('binding descriptions', () => {
+ function mapToObject<K, V>(m: Map<K, V>) {
+ const o: any = {};
+ m.forEach((v: V, k: K) => (o[k] = v));
+ return o;
+ }
+
+ test('single combo description', () => {
+ const mgr = new ShortcutsService();
+ assert.deepEqual(mgr.describeBinding('a'), ['a']);
+ assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+ assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
+ assert.deepEqual(mgr.describeBinding('ctrl+shift+up:keyup'), [
+ 'Ctrl',
+ 'Shift',
+ '↑',
+ ]);
+ });
+
+ test('combo set description', () => {
+ const mgr = new ShortcutsService();
+ assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
+
+ mgr.bindShortcut(
+ Shortcut.GO_TO_OPENED_CHANGES,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'o'
+ );
+ assert.deepEqual(mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES), [
+ ['g', 'o'],
+ ]);
+
+ mgr.bindShortcut(
+ Shortcut.NEXT_FILE,
+ SPECIAL_SHORTCUT.DOC_ONLY,
+ ']',
+ 'ctrl+shift+right:keyup'
+ );
+ assert.deepEqual(mgr.describeBindings(Shortcut.NEXT_FILE), [
+ [']'],
+ ['Ctrl', 'Shift', '→'],
+ ]);
+
+ mgr.bindShortcut(Shortcut.PREV_FILE, '[');
+ assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
+ });
+
+ test('combo set description width', () => {
+ const mgr = new ShortcutsService();
+ assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+ assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+ assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+ assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+ assert.strictEqual(
+ mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+ 12
+ );
+ });
+
+ test('distribute shortcut help', () => {
+ const mgr = new ShortcutsService();
+ assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+ assert.deepEqual(mgr.distributeBindingDesc([['g', 'o']]), [[['g', 'o']]]);
+ assert.deepEqual(
+ mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+ [[['ctrl', 'shift', 'meta', 'enter']]]
+ );
+ assert.deepEqual(
+ mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter'], ['o']]),
+ [[['ctrl', 'shift', 'meta', 'enter']], [['o']]]
+ );
+ assert.deepEqual(
+ mgr.distributeBindingDesc([
+ ['ctrl', 'enter'],
+ ['meta', 'enter'],
+ ['ctrl', 's'],
+ ['meta', 's'],
+ ]),
+ [
+ [
+ ['ctrl', 'enter'],
+ ['meta', 'enter'],
+ ],
+ [
+ ['ctrl', 's'],
+ ['meta', 's'],
+ ],
+ ]
+ );
+ });
+
+ test('active shortcuts by section', () => {
+ const mgr = new ShortcutsService();
+ mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
+ mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
+ mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
+ mgr.bindShortcut(Shortcut.SEARCH, '/');
+
+ assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {});
+
+ mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, 'null']]));
+ assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+ [ShortcutSection.NAVIGATION]: [
+ {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+ ],
+ });
+
+ mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, 'null']]));
+ assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+ [ShortcutSection.DIFFS]: [
+ {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
+ ],
+ [ShortcutSection.NAVIGATION]: [
+ {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+ ],
+ });
+
+ mgr.attachHost(
+ {},
+ new Map([
+ [Shortcut.SEARCH, 'null'],
+ [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
+ ])
+ );
+ assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+ [ShortcutSection.DIFFS]: [
+ {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
+ ],
+ [ShortcutSection.EVERYWHERE]: [
+ {shortcut: Shortcut.SEARCH, text: 'Search'},
+ {
+ shortcut: Shortcut.GO_TO_OPENED_CHANGES,
+ text: 'Go to Opened Changes',
+ },
+ ],
+ [ShortcutSection.NAVIGATION]: [
+ {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
+ ],
+ });
+ });
+
+ test('directory view', () => {
+ const mgr = new ShortcutsService();
+ mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
+ mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
+ mgr.bindShortcut(
+ Shortcut.GO_TO_OPENED_CHANGES,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'o'
+ );
+ mgr.bindShortcut(Shortcut.SEARCH, '/');
+ mgr.bindShortcut(
+ Shortcut.SAVE_COMMENT,
+ 'ctrl+enter',
+ 'meta+enter',
+ 'ctrl+s',
+ 'meta+s'
+ );
+
+ assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+ mgr.attachHost(
+ {},
+ new Map([
+ [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
+ [Shortcut.NEXT_FILE, 'null'],
+ [Shortcut.NEXT_LINE, 'null'],
+ [Shortcut.SAVE_COMMENT, 'null'],
+ [Shortcut.SEARCH, 'null'],
+ ])
+ );
+ assert.deepEqual(mapToObject(mgr.directoryView()), {
+ [ShortcutSection.DIFFS]: [
+ {binding: [['j']], text: 'Go to next line'},
+ {
+ binding: [
+ ['Ctrl', 'Enter'],
+ ['Meta', 'Enter'],
+ ],
+ text: 'Save comment',
+ },
+ {
+ binding: [
+ ['Ctrl', 's'],
+ ['Meta', 's'],
+ ],
+ text: 'Save comment',
+ },
+ ],
+ [ShortcutSection.EVERYWHERE]: [
+ {binding: [['/']], text: 'Search'},
+ {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+ ],
+ [ShortcutSection.NAVIGATION]: [
+ {binding: [[']']], text: 'Go to next file'},
+ ],
+ });
+ });
+ });
+});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 550d3df..bd01b9d 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -32,7 +32,6 @@
removeIronOverlayBackdropStyleEl,
TestKeyboardShortcutBinder,
} from './test-utils';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {safeTypesBridge} from '../utils/safe-types-util';
import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
import {initGlobalVariables} from '../elements/gr-app-global-var-init';
@@ -45,6 +44,7 @@
import {cleanUpStorage} from '../services/storage/gr-storage_mock';
import {updatePreferences} from '../services/user/user-model';
import {createDefaultPreferences} from '../constants/constants';
+import {appContext} from '../services/app-context';
declare global {
interface Window {
@@ -101,14 +101,14 @@
// If the following asserts fails - then window.stub is
// overwritten by some other code.
assert.equal(getCleanupsCount(), 0);
+ _testOnlyInitAppContext();
// The following calls is nessecary to avoid influence of previously executed
// tests.
TestKeyboardShortcutBinder.push();
- _testOnlyInitAppContext();
initGlobalVariables();
_testOnly_initGerritPluginApi();
- const mgr = _testOnly_getShortcutManagerInstance();
- assert.isTrue(mgr._testOnly_isEmpty());
+ const shortcuts = appContext.shortcutsService;
+ assert.isTrue(shortcuts._testOnly_isEmpty());
const selection = document.getSelection();
if (selection) {
selection.removeAllRanges();
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index a60c1d1..12496e0 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -17,10 +17,7 @@
import '../types/globals';
import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
-import {
- _testOnly_getShortcutManagerInstance,
- Shortcut,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {Shortcut} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {appContext} from '../services/app-context';
import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
import {SinonSpy} from 'sinon';
@@ -62,7 +59,7 @@
static push() {
const testBinder = new TestKeyboardShortcutBinder();
this.stack.push(testBinder);
- return _testOnly_getShortcutManagerInstance();
+ return appContext.shortcutsService;
}
static pop() {
@@ -77,13 +74,12 @@
constructor() {
this.originalBinding = new Map(
- _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
+ appContext.shortcutsService._testOnly_getBindings()
);
}
_restoreShortcuts() {
- const bindings =
- _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
+ const bindings = appContext.shortcutsService._testOnly_getBindings();
bindings.clear();
this.originalBinding.forEach((value, key) => {
bindings.set(key, value);
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 0002254..9e3bc74 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -104,8 +104,11 @@
selector: string
): E | undefined {
if (!el) return undefined;
- const root = el.shadowRoot ?? el;
- return root.querySelector<E>(selector) ?? undefined;
+ if (el.shadowRoot) {
+ const r = el.shadowRoot.querySelector<E>(selector);
+ if (r) return r;
+ }
+ return el.querySelector<E>(selector) ?? undefined;
}
export function queryAndAssert<E extends Element = Element>(