chrome-devtools-frontend
Version:
Chrome DevTools UI
445 lines (390 loc) • 14.4 kB
text/typescript
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../../../core/common/common.js';
import * as Host from '../../../../core/host/host.js';
import * as i18n from '../../../../core/i18n/i18n.js';
import * as Platform from '../../../../core/platform/platform.js';
import * as Diff from '../../../../third_party/diff/diff.js';
import * as IconButton from '../../../components/icon_button/icon_button.js';
import * as UI from '../../legacy.js';
import {FilteredListWidget, Provider, registerProvider} from './FilteredListWidget.js';
import {QuickOpenImpl} from './QuickOpen.js';
const UIStrings = {
/**
* @description Message to display if a setting change requires a reload of DevTools
*/
oneOrMoreSettingsHaveChanged: 'One or more settings have changed which requires a reload to take effect',
/**
* @description Text in Command Menu of the Command Menu
*/
noCommandsFound: 'No commands found',
/**
* @description Text for command prefix of run a command
*/
run: 'Run',
/**
* @description Text for command suggestion of run a command
*/
command: 'Command',
/**
* @description Text for help title of run command menu
*/
runCommand: 'Run command',
/**
* @description Hint text to indicate that a selected command is deprecated
*/
deprecated: '— deprecated',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/quick_open/CommandMenu.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
let commandMenuInstance: CommandMenu;
export class CommandMenu {
private readonly commandsInternal: Command[];
private constructor() {
this.commandsInternal = [];
this.loadCommands();
}
static instance(opts: {
forceNew: boolean|null,
} = {forceNew: null}): CommandMenu {
const {forceNew} = opts;
if (!commandMenuInstance || forceNew) {
commandMenuInstance = new CommandMenu();
}
return commandMenuInstance;
}
static createCommand(options: CreateCommandOptions): Command {
const {
category,
keys,
title,
shortcut,
jslogContext,
executeHandler,
availableHandler,
userActionCode,
deprecationWarning,
isPanelOrDrawer,
} = options;
let handler = executeHandler;
if (userActionCode) {
const actionCode = userActionCode;
handler = () => {
Host.userMetrics.actionTaken(actionCode);
executeHandler();
};
}
return new Command(
category, title, keys, shortcut, jslogContext, handler, availableHandler, deprecationWarning, isPanelOrDrawer);
}
static createSettingCommand<V>(setting: Common.Settings.Setting<V>, title: Common.UIString.LocalizedString, value: V):
Command {
const category = setting.category();
if (!category) {
throw new Error(`Creating '${title}' setting command failed. Setting has no category.`);
}
const tags = setting.tags() || '';
const reloadRequired = Boolean(setting.reloadRequired());
return CommandMenu.createCommand({
category: Common.Settings.getLocalizedSettingsCategory(category),
keys: tags,
title,
shortcut: '',
jslogContext: Platform.StringUtilities.toKebabCase(`${setting.name}-${value}`),
executeHandler: () => {
if (setting.deprecation?.disabled &&
(!setting.deprecation?.experiment || setting.deprecation.experiment.isEnabled())) {
void Common.Revealer.reveal(setting);
return;
}
setting.set(value);
if (setting.name === 'emulate-page-focus') {
Host.userMetrics.actionTaken(Host.UserMetrics.Action.ToggleEmulateFocusedPageFromCommandMenu);
}
if (reloadRequired) {
UI.InspectorView.InspectorView.instance().displayReloadRequiredWarning(
i18nString(UIStrings.oneOrMoreSettingsHaveChanged));
}
},
availableHandler,
deprecationWarning: setting.deprecation?.warning,
});
function availableHandler(): boolean {
return setting.get() !== value;
}
}
static createActionCommand(options: ActionCommandOptions): Command {
const {action, userActionCode} = options;
const category = action.category();
if (!category) {
throw new Error(`Creating '${action.title()}' action command failed. Action has no category.`);
}
let panelOrDrawer = undefined;
if (category === UI.ActionRegistration.ActionCategory.DRAWER) {
panelOrDrawer = PanelOrDrawer.DRAWER;
}
const shortcut = UI.ShortcutRegistry.ShortcutRegistry.instance().shortcutTitleForAction(action.id()) || '';
return CommandMenu.createCommand({
category: UI.ActionRegistration.getLocalizedActionCategory(category),
keys: action.tags() || '',
title: action.title(),
shortcut,
jslogContext: action.id(),
executeHandler: action.execute.bind(action),
userActionCode,
availableHandler: undefined,
isPanelOrDrawer: panelOrDrawer,
});
}
static createRevealViewCommand(options: RevealViewCommandOptions): Command {
const {title, tags, category, userActionCode, id} = options;
if (!category) {
throw new Error(`Creating '${title}' reveal view command failed. Reveal view has no category.`);
}
let panelOrDrawer = undefined;
if (category === UI.ViewManager.ViewLocationCategory.PANEL) {
panelOrDrawer = PanelOrDrawer.PANEL;
} else if (category === UI.ViewManager.ViewLocationCategory.DRAWER) {
panelOrDrawer = PanelOrDrawer.DRAWER;
}
const executeHandler = (): Promise<void> => {
if (id === 'issues-pane') {
Host.userMetrics.issuesPanelOpenedFrom(Host.UserMetrics.IssueOpener.COMMAND_MENU);
}
return UI.ViewManager.ViewManager.instance().showView(id, /* userGesture */ true);
};
return CommandMenu.createCommand({
category: UI.ViewManager.getLocalizedViewLocationCategory(category),
keys: tags,
title,
shortcut: '',
jslogContext: id,
executeHandler,
userActionCode,
availableHandler: undefined,
isPanelOrDrawer: panelOrDrawer,
});
}
private loadCommands(): void {
const locations = new Map<UI.ViewManager.ViewLocationValues, UI.ViewManager.ViewLocationCategory>();
for (const {category, name} of UI.ViewManager.getRegisteredLocationResolvers()) {
if (category && name) {
locations.set(name, category);
}
}
const views = UI.ViewManager.getRegisteredViewExtensions();
for (const view of views) {
const viewLocation = view.location();
const category = viewLocation && locations.get(viewLocation);
if (!category) {
continue;
}
const options: RevealViewCommandOptions = {
title: view.commandPrompt(),
tags: view.tags() || '',
category,
id: view.viewId(),
};
this.commandsInternal.push(CommandMenu.createRevealViewCommand(options));
}
// Populate allowlisted settings.
const settingsRegistrations = Common.Settings.Settings.instance().getRegisteredSettings();
for (const settingRegistration of settingsRegistrations) {
const options = settingRegistration.options;
if (!options || !settingRegistration.category) {
continue;
}
for (const pair of options) {
const setting = Common.Settings.Settings.instance().moduleSetting(settingRegistration.settingName);
this.commandsInternal.push(CommandMenu.createSettingCommand(setting, pair.title(), pair.value));
}
}
}
commands(): Command[] {
return this.commandsInternal;
}
}
export interface ActionCommandOptions {
action: UI.ActionRegistration.Action;
userActionCode?: number;
}
export interface RevealViewCommandOptions {
id: string;
title: Common.UIString.LocalizedString;
tags: string;
category: UI.ViewManager.ViewLocationCategory;
userActionCode?: number;
}
export interface CreateCommandOptions {
category: Platform.UIString.LocalizedString;
keys: string;
title: Common.UIString.LocalizedString;
shortcut: string;
jslogContext: string;
executeHandler: () => void;
availableHandler?: () => boolean;
userActionCode?: number;
deprecationWarning?: Platform.UIString.LocalizedString;
isPanelOrDrawer?: PanelOrDrawer;
}
export const enum PanelOrDrawer {
PANEL = 'PANEL',
DRAWER = 'DRAWER',
}
export class CommandMenuProvider extends Provider {
private commands: Command[];
constructor(commandsForTest: Command[] = []) {
super('command');
this.commands = commandsForTest;
}
override attach(): void {
const allCommands = CommandMenu.instance().commands();
// Populate allowlisted actions.
const actions = UI.ActionRegistry.ActionRegistry.instance().availableActions();
for (const action of actions) {
const category = action.category();
if (!category) {
continue;
}
this.commands.push(CommandMenu.createActionCommand({action}));
}
for (const command of allCommands) {
if (!command.available()) {
continue;
}
if (this.commands.find(({title, category}) => title === command.title && category === command.category)) {
continue;
}
this.commands.push(command);
}
this.commands = this.commands.sort(commandComparator);
function commandComparator(left: Command, right: Command): number {
const cats = Platform.StringUtilities.compare(left.category, right.category);
return cats ? cats : Platform.StringUtilities.compare(left.title, right.title);
}
}
override detach(): void {
this.commands = [];
}
override itemCount(): number {
return this.commands.length;
}
override itemKeyAt(itemIndex: number): string {
return this.commands[itemIndex].key;
}
override itemScoreAt(itemIndex: number, query: string): number {
const command = this.commands[itemIndex];
let score = Diff.Diff.DiffWrapper.characterScore(query.toLowerCase(), command.title.toLowerCase());
// Score panel/drawer reveals above regular actions.
if (command.isPanelOrDrawer === PanelOrDrawer.PANEL) {
score += 2;
} else if (command.isPanelOrDrawer === PanelOrDrawer.DRAWER) {
score += 1;
}
return score;
}
override renderItem(itemIndex: number, query: string, titleElement: Element, subtitleElement: Element): void {
const command = this.commands[itemIndex];
titleElement.removeChildren();
const icon = IconButton.Icon.create(categoryIcons[command.category]);
titleElement.parentElement?.parentElement?.insertBefore(icon, titleElement.parentElement);
UI.UIUtils.createTextChild(titleElement, command.title);
FilteredListWidget.highlightRanges(titleElement, query, true);
subtitleElement.textContent = command.shortcut;
const deprecationWarning = command.deprecationWarning;
if (deprecationWarning) {
const deprecatedTagElement = titleElement.parentElement?.createChild('span', 'deprecated-tag');
if (deprecatedTagElement) {
deprecatedTagElement.textContent = i18nString(UIStrings.deprecated);
deprecatedTagElement.title = deprecationWarning;
}
}
const tagElement = titleElement.parentElement?.parentElement?.createChild('span', 'tag');
if (!tagElement) {
return;
}
tagElement.textContent = command.category;
}
override jslogContextAt(itemIndex: number): string {
return this.commands[itemIndex].jslogContext;
}
override selectItem(itemIndex: number|null, _promptValue: string): void {
if (itemIndex === null) {
return;
}
this.commands[itemIndex].execute();
Host.userMetrics.actionTaken(Host.UserMetrics.Action.SelectCommandFromCommandMenu);
}
override notFoundText(): string {
return i18nString(UIStrings.noCommandsFound);
}
}
const categoryIcons: Record<string, string> = {
Appearance: 'palette',
Console: 'terminal',
Debugger: 'bug',
Drawer: 'keyboard-full',
Elements: 'code',
Global: 'global',
Grid: 'grid-on',
Help: 'help',
Mobile: 'devices',
Navigation: 'refresh',
Network: 'arrow-up-down',
Panel: 'frame',
Performance: 'performance',
Persistence: 'override',
Recorder: 'record-start',
Rendering: 'tonality',
Resources: 'bin',
Screenshot: 'photo-camera',
Settings: 'gear',
Sources: 'label',
};
export class Command {
readonly category: Common.UIString.LocalizedString;
readonly title: Common.UIString.LocalizedString;
readonly key: string;
readonly shortcut: string;
readonly jslogContext: string;
readonly deprecationWarning?: Platform.UIString.LocalizedString;
readonly isPanelOrDrawer?: PanelOrDrawer;
readonly #executeHandler: () => unknown;
readonly #availableHandler?: () => boolean;
constructor(
category: Common.UIString.LocalizedString, title: Common.UIString.LocalizedString, key: string, shortcut: string,
jslogContext: string, executeHandler: () => unknown, availableHandler?: () => boolean,
deprecationWarning?: Platform.UIString.LocalizedString, isPanelOrDrawer?: PanelOrDrawer) {
this.category = category;
this.title = title;
this.key = category + '\0' + title + '\0' + key;
this.shortcut = shortcut;
this.jslogContext = jslogContext;
this.#executeHandler = executeHandler;
this.#availableHandler = availableHandler;
this.deprecationWarning = deprecationWarning;
this.isPanelOrDrawer = isPanelOrDrawer;
}
available(): boolean {
return this.#availableHandler ? this.#availableHandler() : true;
}
execute(): unknown {
return this.#executeHandler(); // Tests might want to await the action in case it's async.
}
}
export class ShowActionDelegate implements UI.ActionRegistration.ActionDelegate {
handleAction(_context: UI.Context.Context, _actionId: string): boolean {
Host.InspectorFrontendHost.InspectorFrontendHostInstance.bringToFront();
QuickOpenImpl.show('>');
return true;
}
}
registerProvider({
prefix: '>',
iconName: 'chevron-right',
provider: () => Promise.resolve(new CommandMenuProvider()),
helpTitle: () => i18nString(UIStrings.runCommand),
titlePrefix: () => i18nString(UIStrings.run),
titleSuggestion: () => i18nString(UIStrings.command),
});